m / app.py
Eluza133's picture
Update app.py
f81c19d verified
raw
history blame
112 kB
# --- START OF FILE app (24).py ---
import os
import hmac
import hashlib
import json
from urllib.parse import unquote, parse_qsl, urlencode
from flask import Flask, request, jsonify, Response, send_file
from flask_caching import Cache
import logging
import threading
import time
from datetime import datetime
from huggingface_hub import HfApi, hf_hub_download, utils as hf_utils
from werkzeug.utils import secure_filename
import requests
from io import BytesIO
import uuid
from typing import Union, Optional
import shutil
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_mini_app_unique_v2")
BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4')
DATA_FILE = 'cloudeng_mini_app_data.json'
DATA_FILE_TEMP = DATA_FILE + '.tmp'
DATA_FILE_BACKUP = DATA_FILE + '.bak'
REPO_ID = "Eluza133/Z1e1u"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE
UPLOAD_FOLDER = 'uploads_mini_app'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
cache = Cache(app, config={'CACHE_TYPE': 'simple'})
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
AUTH_DATA_LIFETIME = 3600
def find_node_by_id(filesystem, node_id):
if not filesystem or not isinstance(filesystem, dict):
return None, None
if filesystem.get('id') == node_id:
return filesystem, None
queue = [(filesystem, None)]
visited = {filesystem.get('id')}
while queue:
current_node, parent = queue.pop(0)
node_type = current_node.get('type')
node_children = current_node.get('children')
if node_type == 'folder' and isinstance(node_children, list):
for child in node_children:
if not isinstance(child, dict): continue
child_id = child.get('id')
if not child_id: continue
if child_id == node_id:
return child, current_node
if child_id not in visited and child.get('type') == 'folder':
visited.add(child_id)
queue.append((child, current_node))
return None, None
def add_node(filesystem, parent_id, node_data):
parent_node, _ = find_node_by_id(filesystem, parent_id)
if parent_node and parent_node.get('type') == 'folder':
if 'children' not in parent_node or not isinstance(parent_node['children'], list):
parent_node['children'] = []
existing_ids = {child.get('id') for child in parent_node['children'] if isinstance(child, dict)}
if node_data.get('id') not in existing_ids:
parent_node['children'].append(node_data)
return True
return False
def remove_node(filesystem, node_id):
node_to_remove, parent_node = find_node_by_id(filesystem, node_id)
if node_to_remove and parent_node and 'children' in parent_node and isinstance(parent_node['children'], list):
original_length = len(parent_node['children'])
parent_node['children'] = [child for child in parent_node['children'] if not isinstance(child, dict) or child.get('id') != node_id]
return len(parent_node['children']) < original_length
if node_to_remove and node_id == filesystem.get('id'):
logging.warning("Attempted to remove root node directly.")
return False
return False
def get_node_path_list(filesystem, node_id):
path_list = []
current_id = node_id
processed_ids = set()
max_depth = 20
depth = 0
while current_id and current_id not in processed_ids and depth < max_depth:
processed_ids.add(current_id)
depth += 1
node, parent = find_node_by_id(filesystem, current_id)
if not node or not isinstance(node, dict):
logging.error(f"Path traversal failed: Node not found or invalid for ID {current_id}")
break
path_list.append({
'id': node.get('id'),
'name': node.get('name', node.get('original_filename', 'Unknown'))
})
if not parent or not isinstance(parent, dict):
if node.get('id') != 'root':
logging.warning(f"Node {current_id} has no parent, stopping path traversal.")
break
parent_id = parent.get('id')
if parent_id == current_id:
logging.error(f"Filesystem loop detected at node {current_id}")
break
current_id = parent_id
if not any(p['id'] == 'root' for p in path_list):
root_node, _ = find_node_by_id(filesystem, 'root')
if root_node:
path_list.append({'id': 'root', 'name': root_node.get('name', 'Root')})
else:
path_list.append({'id': 'root', 'name': 'Root'})
final_path = []
seen_ids = set()
for item in reversed(path_list):
if item['id'] not in seen_ids:
final_path.append(item)
seen_ids.add(item['id'])
return final_path
def initialize_user_filesystem(user_data):
if not isinstance(user_data, dict):
logging.error("Invalid user_data passed to initialize_user_filesystem")
return
if 'filesystem' not in user_data or not isinstance(user_data.get('filesystem'), dict) or not user_data['filesystem'].get('id') == 'root':
logging.warning(f"Initializing/Resetting filesystem for user data fragment: {str(user_data)[:100]}")
user_data['filesystem'] = {
"type": "folder",
"id": "root",
"name": "Root",
"children": []
}
elif 'children' not in user_data['filesystem'] or not isinstance(user_data['filesystem']['children'], list):
logging.warning(f"Fixing missing/invalid children array for root filesystem: {str(user_data)[:100]}")
user_data['filesystem']['children'] = []
def load_data_from_file(filepath):
try:
with open(filepath, 'r', encoding='utf-8') as file:
data = json.load(file)
if not isinstance(data, dict):
logging.warning(f"Data in {filepath} is not a dict, using empty.")
return {'users': {}}
data.setdefault('users', {})
# Deep check and initialization
users_copy = data.get('users', {})
if not isinstance(users_copy, dict):
logging.warning(f"Users field in {filepath} is not a dict, resetting users.")
data['users'] = {}
return data
for user_id, user_data in list(users_copy.items()): # Use list to allow potential removal during iteration
if not isinstance(user_data, dict):
logging.warning(f"Invalid user data structure for user {user_id} in {filepath}, removing entry.")
del data['users'][user_id]
continue
initialize_user_filesystem(user_data)
logging.info(f"Data loaded successfully from {filepath}")
return data
except FileNotFoundError:
logging.warning(f"{filepath} not found.")
return None
except json.JSONDecodeError:
logging.error(f"Error decoding JSON from {filepath}.")
return None
except Exception as e:
logging.error(f"Error loading data from {filepath}: {e}")
return None
@cache.memoize(timeout=60)
def load_data():
logging.info("Attempting to load data...")
# 1. Try to download from HF
download_success = download_db_from_hf()
# 2. Try loading the main file
data = load_data_from_file(DATA_FILE)
if data is not None:
logging.info("Using main data file.")
return data
# 3. If main file failed or didn't exist (and download might have failed), try backup
logging.warning("Main data file failed to load or not found, trying backup.")
data = load_data_from_file(DATA_FILE_BACKUP)
if data is not None:
logging.info("Using backup data file.")
# Attempt to restore main file from backup
try:
shutil.copy(DATA_FILE_BACKUP, DATA_FILE)
logging.info(f"Restored {DATA_FILE} from {DATA_FILE_BACKUP}")
except Exception as e:
logging.error(f"Failed to restore main file from backup: {e}")
return data
# 4. If both fail, initialize empty structure
logging.error("Both main and backup data files are missing or corrupt. Initializing empty data.")
return {'users': {}}
def save_data(data):
if not isinstance(data, dict) or not isinstance(data.get('users'), dict):
logging.critical(f"CRITICAL: Attempted to save invalid data structure: {str(data)[:200]}. Aborting save.")
# Optionally raise an exception or handle more gracefully
return False # Indicate save failure
try:
# Write to temporary file first
with open(DATA_FILE_TEMP, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
# If temporary write succeeded, create backup and then rename
if os.path.exists(DATA_FILE):
try:
shutil.copy(DATA_FILE, DATA_FILE_BACKUP) # More robust than rename for backup
logging.info(f"Created backup: {DATA_FILE_BACKUP}")
except Exception as e:
logging.warning(f"Could not create backup file {DATA_FILE_BACKUP}: {e}")
shutil.move(DATA_FILE_TEMP, DATA_FILE) # Atomic rename/move
cache.clear() # Clear cache after successful save
logging.info("Data saved successfully to " + DATA_FILE)
# Schedule HF upload (run_as_future makes it non-blocking)
upload_thread = threading.Thread(target=upload_db_to_hf)
upload_thread.start()
return True # Indicate save success
except Exception as e:
logging.error(f"Error saving data: {e}")
# Clean up temp file if it exists
if os.path.exists(DATA_FILE_TEMP):
try:
os.remove(DATA_FILE_TEMP)
except OSError as e_rm:
logging.error(f"Error removing temporary save file {DATA_FILE_TEMP}: {e_rm}")
return False # Indicate save failure
def upload_db_to_hf():
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_WRITE not set, skipping database upload.")
return
if not os.path.exists(DATA_FILE):
logging.warning(f"Data file {DATA_FILE} not found for upload.")
return
try:
api = HfApi()
api.upload_file(
path_or_fileobj=DATA_FILE,
path_in_repo=DATA_FILE,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Backup MiniApp {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
# run_as_future=True # Already running in a separate thread from save_data
)
logging.info("Database upload to Hugging Face completed.")
except Exception as e:
logging.error(f"Error during database upload: {e}")
def download_db_from_hf():
if not HF_TOKEN_READ:
logging.warning("HF_TOKEN_READ not set, skipping database download.")
return False
local_path_tmp = DATA_FILE + ".hf_download"
try:
logging.info(f"Attempting download of {DATA_FILE} from {REPO_ID}")
hf_hub_download(
repo_id=REPO_ID,
filename=DATA_FILE,
repo_type="dataset",
token=HF_TOKEN_READ,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True, # Ensure we get the latest
etag_timeout=10,
resume_download=False,
cache_dir=None, # Don't use HF cache, write directly
local_path=local_path_tmp # Download to temp file first
)
# Verify downloaded file is valid JSON before replacing
if load_data_from_file(local_path_tmp) is not None:
shutil.move(local_path_tmp, DATA_FILE)
logging.info("Database downloaded successfully from Hugging Face and verified.")
cache.clear() # Clear cache as data might have changed
return True
else:
logging.error("Downloaded database file is invalid JSON. Discarding download.")
os.remove(local_path_tmp)
return False
except hf_utils.RepositoryNotFoundError:
logging.error(f"Repository {REPO_ID} not found on Hugging Face.")
return False
except hf_utils.EntryNotFoundError:
logging.warning(f"{DATA_FILE} not found in repo {REPO_ID}. Using local/backup if available.")
return False
except requests.exceptions.RequestException as e:
logging.error(f"Connection error downloading DB from HF: {e}. Using local/backup.")
return False
except Exception as e:
logging.error(f"Generic error downloading database: {e}")
# Clean up potentially partial download
if os.path.exists(local_path_tmp):
try: os.remove(local_path_tmp)
except OSError: pass
return False
def get_file_type(filename):
if not filename or '.' not in filename: return 'other'
ext = filename.lower().split('.')[-1]
if ext in ['mp4', 'mov', 'avi', 'webm', 'mkv', 'm4v', 'quicktime']: return 'video'
if ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'heic', 'heif']: return 'image'
if ext == 'pdf': return 'pdf'
if ext in ['txt', 'md', 'log', 'csv', 'json', 'xml', 'html', 'css', 'js', 'py', 'java', 'c', 'cpp']: return 'text'
if ext in ['doc', 'docx', 'rtf']: return 'doc'
if ext in ['xls', 'xlsx']: return 'sheet'
if ext in ['ppt', 'pptx']: return 'slides'
if ext in ['zip', 'rar', '7z', 'gz', 'tar']: return 'archive'
if ext in ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a']: return 'audio'
return 'other'
def check_telegram_authorization(auth_data: str, bot_token: str) -> Optional[dict]:
if not auth_data or not bot_token or bot_token == 'YOUR_BOT_TOKEN':
logging.warning("Validation skipped: Missing auth_data or valid BOT_TOKEN.")
return None # Consider returning a specific error?
try:
parsed_data = dict(parse_qsl(unquote(auth_data)))
if "hash" not in parsed_data:
logging.error("Hash not found in auth data")
return None
telegram_hash = parsed_data.pop('hash')
auth_date_ts = int(parsed_data.get('auth_date', 0))
current_ts = int(time.time())
if abs(current_ts - auth_date_ts) > AUTH_DATA_LIFETIME:
logging.warning(f"Auth data expired (Auth: {auth_date_ts}, Now: {current_ts}, Diff: {current_ts - auth_date_ts})")
# return None # Temporarily disable expiration check for easier testing if needed
pass # Allow expired data for now, maybe add strict mode later
data_check_string = "\n".join(sorted([f"{k}={v}" for k, v in parsed_data.items()]))
secret_key = hmac.new("WebAppData".encode(), bot_token.encode(), hashlib.sha256).digest()
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if hmac.compare_digest(calculated_hash, telegram_hash):
user_data_str = parsed_data.get('user')
if user_data_str:
try:
user_info = json.loads(user_data_str)
if 'id' not in user_info:
logging.error("Validated user data missing 'id'")
return None
logging.info(f"Validation successful for user ID: {user_info.get('id')}")
return user_info
except json.JSONDecodeError:
logging.error("Failed to decode user JSON from auth data")
return None
else:
logging.warning("No 'user' field in validated auth data")
return None
else:
logging.warning("Hash mismatch during validation")
return None
except Exception as e:
logging.error(f"Exception during validation: {e}", exc_info=True)
return None
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Cloud Eng</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
:root {
--tg-theme-bg-color: var(--tg-bg-color, #ffffff);
--tg-theme-text-color: var(--tg-text-color, #000000);
--tg-theme-hint-color: var(--tg-hint-color, #999999);
--tg-theme-link-color: var(--tg-link-color, #007aff);
--tg-theme-button-color: var(--tg-button-color, #007aff);
--tg-theme-button-text-color: var(--tg-button-text-color, #ffffff);
--tg-theme-secondary-bg-color: var(--tg-secondary-bg-color, #f0f0f0);
--tg-viewport-height: var(--tg-viewport-stable-height, 100vh);
--system-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--border-radius-s: 8px;
--border-radius-m: 12px;
--border-radius-l: 16px;
--padding-s: 8px;
--padding-m: 12px;
--padding-l: 16px;
--gap-s: 8px;
--gap-m: 12px;
--gap-l: 16px;
--delete-color: #ff3b30;
--folder-color: #ffcc00;
--file-icon-color: #aaaaaa;
--shadow-color: rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--tg-theme-bg-color: var(--tg-bg-color, #000000);
--tg-theme-text-color: var(--tg-text-color, #ffffff);
--tg-theme-hint-color: var(--tg-hint-color, #8e8e93);
--tg-theme-link-color: var(--tg-link-color, #0a84ff);
--tg-theme-button-color: var(--tg-button-color, #0a84ff);
--tg-theme-button-text-color: var(--tg-button-text-color, #ffffff);
--tg-theme-secondary-bg-color: var(--tg-secondary-bg-color, #1c1c1e);
--delete-color: #ff453a;
--folder-color: #ffd60a;
--file-icon-color: #666666;
--shadow-color: rgba(255, 255, 255, 0.1);
}
}
html { box-sizing: border-box; }
*, *:before, *:after { box-sizing: inherit; }
body {
font-family: var(--system-font);
background-color: var(--tg-theme-bg-color);
color: var(--tg-theme-text-color);
line-height: 1.4;
margin: 0;
padding: 0; /* Remove default padding */
min-height: var(--tg-viewport-height);
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior-y: none; /* Prevent pull-to-refresh issues */
font-size: 16px;
}
.app-container {
width: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
padding: var(--padding-l) var(--padding-l) 0; /* Add padding, but not at the bottom initially */
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--padding-l)); /* Add padding for home bar */
}
#loading, #error-view {
padding: 30px var(--padding-l); text-align: center; font-size: 1.1em;
display: flex; justify-content: center; align-items: center; flex-direction: column; flex-grow: 1;
color: var(--tg-theme-hint-color);
}
#error-view h2 { color: var(--delete-color); margin-bottom: var(--padding-m); }
#error-view p { margin-bottom: var(--padding-l); }
#app-content { display: none; flex-grow: 1; flex-direction: column; }
.main-header {
font-size: 2em;
font-weight: 700;
text-align: left;
margin-bottom: var(--padding-l);
padding-left: 0; /* Header aligns with content */
color: var(--tg-theme-text-color);
}
.user-info-header {
text-align: left;
margin-bottom: var(--padding-l);
font-size: 0.85em;
color: var(--tg-theme-hint-color);
padding-left: 0;
}
#flash-container {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 10px);
left: var(--padding-l);
right: var(--padding-l);
z-index: 10000;
}
.flash {
color: #ffffff;
text-align: center;
margin-bottom: var(--gap-m);
padding: var(--padding-m) var(--padding-l);
border-radius: var(--border-radius-m);
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.flash.show {
opacity: 1;
transform: translateY(0);
}
.flash.success { background-color: #34c759; } /* iOS Green */
.flash.error { background-color: var(--delete-color); }
.breadcrumbs {
margin-bottom: var(--padding-l);
font-size: 0.95em;
background-color: transparent; /* No background for breadcrumbs */
padding: 0;
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
.breadcrumbs::-webkit-scrollbar { display: none; } /* WebKit */
.breadcrumbs a {
color: var(--tg-theme-link-color);
text-decoration: none;
font-weight: 400;
}
.breadcrumbs a:hover { text-decoration: underline; }
.breadcrumbs span.separator {
margin: 0 6px;
color: var(--tg-theme-hint-color);
font-weight: 300;
}
.breadcrumbs span.current-folder {
font-weight: 600;
color: var(--tg-theme-text-color);
}
.actions-section {
margin-bottom: var(--padding-l);
display: flex;
flex-direction: column;
gap: var(--gap-m);
}
.folder-actions { display: flex; gap: var(--gap-m); align-items: center; }
.folder-actions input[type=text] { flex-grow: 1; }
input[type=text], input[type=file], .btn {
border: none;
border-radius: var(--border-radius-m);
font-size: 1em;
font-family: var(--system-font);
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
input[type=text] {
width: 100%;
padding: var(--padding-m);
background-color: var(--tg-theme-secondary-bg-color);
color: var(--tg-theme-text-color);
}
input[type=text]:focus {
outline: none;
/* Maybe add subtle focus ring or slight background change if needed */
}
input[type=file] {
background-color: var(--tg-theme-secondary-bg-color);
color: var(--tg-theme-link-color); /* Use link color for file input */
padding: var(--padding-s);
cursor: pointer;
text-align: center;
display: block; /* Make it take full width */
width: 100%;
}
input[type=file]::file-selector-button {
display: none; /* Hide default ugly button */
}
.btn {
padding: var(--padding-m) var(--padding-l);
background-color: var(--tg-theme-button-color);
color: var(--tg-theme-button-text-color);
border: none;
cursor: pointer;
font-weight: 600;
text-align: center;
display: inline-block;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0; /* Prevent buttons shrinking too much */
}
.btn:active {
transform: scale(0.98); /* Subtle press effect */
opacity: 0.85;
}
.btn[disabled] {
background-color: var(--tg-theme-hint-color);
color: var(--tg-theme-secondary-bg-color);
cursor: not-allowed;
opacity: 0.7;
}
.btn.secondary {
background-color: var(--tg-theme-secondary-bg-color);
color: var(--tg-theme-link-color);
font-weight: 500;
}
.btn.delete { background-color: var(--delete-color); color: white; }
.btn.folder { background-color: var(--folder-color); color: black; } /* Keep folder color distinct */
#progress-container {
width: 100%;
background-color: var(--tg-theme-secondary-bg-color);
border-radius: var(--border-radius-s);
margin: var(--padding-m) 0;
display: none;
height: 6px; /* Slimmer progress bar */
overflow: hidden;
}
#progress-bar {
width: 0%;
height: 100%;
background-color: var(--tg-theme-button-color);
border-radius: var(--border-radius-s);
transition: width 0.3s ease;
}
#progress-text { display: none; } /* Hide percentage text */
.section-title {
font-size: 1.3em;
font-weight: 600;
margin-top: var(--padding-l);
margin-bottom: var(--padding-m);
color: var(--tg-theme-text-color);
padding-left: 0;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); /* Adjust min width for larger items */
gap: var(--gap-l);
padding-bottom: var(--padding-l); /* Add padding at the bottom of the grid */
}
.item {
background-color: transparent; /* Items blend with background */
padding: 0;
border-radius: 0;
text-align: left;
transition: background-color 0.15s ease-out;
display: flex;
flex-direction: column;
position: relative; /* For potential future absolute positioned elements */
}
/* Add subtle hover effect */
.item:hover {
/* background-color: rgba(128, 128, 128, 0.05); */ /* Very subtle hover */
}
.item-preview-container {
width: 100%;
padding-top: 100%; /* Aspect ratio 1:1 */
position: relative;
border-radius: var(--border-radius-l);
overflow: hidden;
background-color: var(--tg-theme-secondary-bg-color);
margin-bottom: var(--gap-s);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.item-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover; /* Cover for images/videos */
display: block;
}
.item-icon {
font-size: 3.5em; /* Larger icons */
line-height: 1;
text-decoration: none;
color: var(--tg-theme-link-color); /* Use link color for icons */
}
.item.folder .item-icon { color: var(--folder-color); }
.item.file.type-video .item-icon { color: #ff2d55; } /* iOS Red */
.item.file.type-image .item-icon { display: none; } /* Hide icon if image preview loads */
.item.file.type-pdf .item-icon { color: #ff9500; } /* iOS Orange */
.item.file.type-text .item-icon { color: #5ac8fa; } /* iOS Light Blue */
.item.file.type-doc .item-icon { color: #007aff; } /* iOS Blue */
.item.file.type-sheet .item-icon { color: #34c759; } /* iOS Green */
.item.file.type-slides .item-icon { color: #ff9500; } /* iOS Orange */
.item.file.type-archive .item-icon { color: #af52de; } /* iOS Purple */
.item.file.type-audio .item-icon { color: #ff2d55; } /* iOS Red */
.item.file.type-other .item-icon { color: var(--tg-theme-hint-color); } /* Default */
.item-info { padding: 0 var(--padding-s); } /* Add slight horizontal padding for text */
.item-info p {
margin: 2px 0;
font-size: 0.9em;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-info p.filename {
font-weight: 500;
color: var(--tg-theme-text-color);
}
.item-info p.details {
font-size: 0.8em;
color: var(--tg-theme-hint-color);
}
.item-actions-trigger {
position: absolute;
top: var(--padding-s);
right: var(--padding-s);
background: rgba(128, 128, 128, 0.2);
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
justify-content: center;
align-items: center;
color: var(--tg-theme-text-color);
cursor: pointer;
z-index: 5;
font-size: 1.2em;
line-height: 1;
}
.actions-menu {
display: none; /* Hidden by default, shown via JS */
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--tg-theme-secondary-bg-color);
border-top-left-radius: var(--border-radius-l);
border-top-right-radius: var(--border-radius-l);
padding: var(--padding-m);
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--padding-m));
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
z-index: 3000;
animation: slideUp 0.3s ease-out forwards;
}
.actions-menu h3 {
font-size: 1.1em;
font-weight: 600;
margin-top: 0;
margin-bottom: var(--padding-m);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions-menu .btn {
display: block; /* Full width buttons */
width: 100%;
margin-bottom: var(--gap-m);
}
.actions-menu .btn:last-child { margin-bottom: 0; }
.actions-menu-backdrop {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.4);
z-index: 2999;
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal {
display: none;
position: fixed;
inset: 0; /* Covers entire viewport */
background: rgba(0, 0, 0, 0.7); /* Darker backdrop */
backdrop-filter: blur(5px); /* iOS style blur */
-webkit-backdrop-filter: blur(5px);
z-index: 2000;
justify-content: center;
align-items: center;
padding: 0; /* No padding on modal container itself */
opacity: 0;
transition: opacity 0.3s ease-out;
}
.modal.show { opacity: 1; }
.modal-content {
width: 100%;
height: 100%;
background: var(--tg-theme-bg-color);
/* No explicit padding, handled by modal-body */
border-radius: 0; /* Full screen */
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: flex-end; /* Place close button on the right */
padding: var(--padding-m);
padding-top: calc(env(safe-area-inset-top, 0px) + var(--padding-m));
position: absolute; /* Position over content */
top: 0; left: 0; right: 0;
z-index: 2002; /* Above content */
background: linear-gradient(to bottom, rgba(0,0,0,0.15), transparent); /* Subtle gradient */
}
.modal-close-btn {
font-size: 1.2em;
font-weight: 600;
color: var(--tg-theme-button-text-color); /* Make it visible on gradient */
cursor: pointer;
background: rgba(0, 0, 0, 0.3); /* Semi-transparent background */
border-radius: 50%;
width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
z-index: 2001;
}
.modal-body {
flex-grow: 1;
overflow: auto;
display: flex;
justify-content: center;
align-items: center;
padding: var(--padding-m); /* Padding around the content */
padding-top: 60px; /* Space for header */
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--padding-m));
}
.modal img, .modal video, .modal iframe, .modal pre {
max-width: 100%;
max-height: 100%; /* Let it fill the body */
display: block;
margin: auto;
border-radius: var(--border-radius-m); /* Slightly rounded content */
}
.modal iframe { width: 100%; height: 100%; border: none; background-color: white; } /* Ensure iframe has white bg */
.modal pre {
background: var(--tg-theme-secondary-bg-color);
color: var(--tg-theme-text-color);
padding: var(--padding-l);
border-radius: var(--border-radius-m);
white-space: pre-wrap;
word-wrap: break-word;
text-align: left;
max-height: 100%;
overflow-y: auto;
width: 100%;
font-size: 0.9em;
}
.empty-folder-message {
text-align: center;
color: var(--tg-theme-hint-color);
padding: 40px 20px;
font-size: 1.1em;
}
/* Responsive Adjustments */
@media (min-width: 600px) { /* Larger screens */
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.app-container {
max-width: 900px;
margin: 0 auto;
padding: var(--padding-l) var(--padding-l); /* Standard padding */
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--padding-l));
}
.modal-content {
max-width: 90vw;
max-height: 90vh;
border-radius: var(--border-radius-l);
}
.modal-header { padding-top: var(--padding-m); } /* Standard padding */
.modal-body { padding-bottom: var(--padding-m); } /* Standard padding */
}
@media (max-width: 380px) { /* Smaller phones */
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: var(--gap-m);
}
.item-icon { font-size: 3em; }
.item-info p { font-size: 0.85em; }
.main-header { font-size: 1.8em; }
.section-title { font-size: 1.2em; }
.btn { padding: var(--padding-m); }
input[type=text] { padding: var(--padding-m); }
}
</style>
</head>
<body>
<div id="loading">
<svg width="40" height="40" viewBox="0 0 50 50"><path fill="currentColor" d="M25,5 A20,20 0 0,1 45,25 A20,20 0 0,1 25,45 A20,20 0 0,1 5,25 A20,20 0 0,1 25,5 z M25,8 A17,17 0 0,0 8,25 A17,17 0 0,0 25,42 A17,17 0 0,0 42,25 A17,17 0 0,0 25,8 z" opacity="0.3"></path><path fill="currentColor" d="M25,3 A22,22 0 0,1 47,25 L44,25 A19,19 0 0,0 25,6 z"><animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.8s" repeatCount="indefinite"></animateTransform></path></svg>
<p style="margin-top: 15px;">Loading...</p>
</div>
<div id="error-view" style="display: none;"></div>
<div id="app-content" class="app-container">
<h1 class="main-header">Cloud Eng</h1>
<div class="user-info-header" id="user-info-header"></div>
<div class="breadcrumbs" id="breadcrumbs-container"></div>
<div class="actions-section">
<div class="folder-actions">
<input type="text" id="new-folder-name" placeholder="New Folder Name" required>
<button id="create-folder-btn" class="btn">Create</button>
</div>
<form id="upload-form" style="margin: 0;">
<label for="file-input" class="btn secondary" style="display: block; width: 100%; text-align: center;">Upload Files</label>
<input type="file" name="files" id="file-input" multiple required style="display: none;">
<button type="submit" id="upload-btn-hidden" style="display: none;">Upload</button> <!-- Hidden submit -->
</form>
<div id="progress-container"><div id="progress-bar"></div></div>
</div>
<h2 class="section-title" id="current-folder-title">Files</h2>
<div class="file-grid" id="file-grid-container">
<!-- Items will be loaded here -->
</div>
<div id="empty-folder-placeholder" class="empty-folder-message" style="display: none;">This folder is empty.</div>
</div>
<div class="modal" id="mediaModal">
<div class="modal-content">
<div class="modal-header">
<span onclick="closeModalManual()" class="modal-close-btn">&times;</span>
</div>
<div class="modal-body" id="modalContent"></div>
</div>
</div>
<div class="actions-menu-backdrop" id="actions-menu-backdrop" onclick="closeActionsMenu()"></div>
<div class="actions-menu" id="actions-menu">
<h3 id="actions-menu-title">Actions</h3>
<div id="actions-menu-buttons">
<!-- Action buttons are added dynamically -->
</div>
<button class="btn secondary" onclick="closeActionsMenu()">Cancel</button>
</div>
<div id="flash-container"></div>
<script>
const tg = window.Telegram.WebApp;
tg.ready();
tg.expand();
const loadingEl = document.getElementById('loading');
const errorViewEl = document.getElementById('error-view');
const appContentEl = document.getElementById('app-content');
const userInfoHeaderEl = document.getElementById('user-info-header');
const flashContainerEl = document.getElementById('flash-container');
const breadcrumbsContainerEl = document.getElementById('breadcrumbs-container');
const fileGridContainerEl = document.getElementById('file-grid-container');
const currentFolderTitleEl = document.getElementById('current-folder-title');
const uploadForm = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
// const uploadBtn = document.getElementById('upload-btn'); // Target the label now
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const newFolderInput = document.getElementById('new-folder-name');
const createFolderBtn = document.getElementById('create-folder-btn');
const mediaModal = document.getElementById('mediaModal');
const modalContent = document.getElementById('modalContent');
const actionsMenu = document.getElementById('actions-menu');
const actionsMenuBackdrop = document.getElementById('actions-menu-backdrop');
const actionsMenuTitle = document.getElementById('actions-menu-title');
const actionsMenuButtons = document.getElementById('actions-menu-buttons');
const emptyFolderPlaceholder = document.getElementById('empty-folder-placeholder');
let currentFolderId = 'root';
let validatedInitData = null;
let currentUser = null;
let currentItems = [];
let apiBaseUrl = ''; // Determined at runtime
// --- Theme Setup ---
function applyTheme() {
const theme = tg.themeParams;
const colorScheme = tg.colorScheme; // 'light' or 'dark'
document.documentElement.style.setProperty('--tg-bg-color', theme.bg_color || (colorScheme === 'dark' ? '#000000' : '#ffffff'));
document.documentElement.style.setProperty('--tg-text-color', theme.text_color || (colorScheme === 'dark' ? '#ffffff' : '#000000'));
document.documentElement.style.setProperty('--tg-hint-color', theme.hint_color || (colorScheme === 'dark' ? '#8e8e93' : '#999999'));
document.documentElement.style.setProperty('--tg-link-color', theme.link_color || (colorScheme === 'dark' ? '#0a84ff' : '#007aff'));
document.documentElement.style.setProperty('--tg-button-color', theme.button_color || (colorScheme === 'dark' ? '#0a84ff' : '#007aff'));
document.documentElement.style.setProperty('--tg-button-text-color', theme.button_text_color || '#ffffff');
document.documentElement.style.setProperty('--tg-secondary-bg-color', theme.secondary_bg_color || (colorScheme === 'dark' ? '#1c1c1e' : '#f0f0f0'));
// Update header color if available
// Note: setHeaderColor affects the *native* header, not elements in the webview body
if (theme.secondary_bg_color) {
// tg.setHeaderColor(theme.secondary_bg_color);
// We might want to set the body background instead for consistency if header isn't used
document.body.style.backgroundColor = theme.bg_color || (colorScheme === 'dark' ? '#000000' : '#ffffff');
} else {
document.body.style.backgroundColor = theme.bg_color || (colorScheme === 'dark' ? '#000000' : '#ffffff');
// tg.setHeaderColor(colorScheme === 'dark' ? '#1c1c1e' : '#f0f0f0'); // Fallback header color
}
// Set main button if needed (we don't use it here)
// tg.MainButton.setText("...");
// tg.MainButton.show();
tg.BackButton.hide(); // Hide by default, show when needed
}
// --- API Communication ---
async function apiCall(endpoint, method = 'POST', body = {}, isFormData = false) {
if (!validatedInitData) {
showError("Authentication data missing. Please reload.");
throw new Error("Not authenticated");
}
const headers = {};
let fetchBody;
if (isFormData) {
// FormData handles its own content type
// Append initData to FormData
body.append('initData', validatedInitData);
fetchBody = body;
} else {
headers['Content-Type'] = 'application/json';
body.initData = validatedInitData; // Add initData to JSON body
fetchBody = JSON.stringify(body);
}
try {
const response = await fetch(apiBaseUrl + endpoint, {
method: method,
headers: headers,
body: fetchBody
});
let responseData;
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
responseData = await response.json();
} else {
// Handle non-JSON responses if necessary, e.g., text error messages
const textResponse = await response.text();
if (!response.ok) {
throw new Error(`Server returned non-JSON error ${response.status}: ${textResponse}`);
}
// If response IS ok but not JSON, maybe return the text?
return { status: 'ok', data: textResponse };
}
if (!response.ok) {
const errorMsg = responseData?.message || `HTTP error ${response.status}`;
throw new Error(errorMsg);
}
if (responseData.status !== 'ok') {
// Handle cases where HTTP status is 200 but API reports an error
throw new Error(responseData.message || 'API operation failed');
}
return responseData; // Return the parsed JSON data
} catch (error) {
console.error(`API call to ${endpoint} failed:`, error);
showFlash(`Network or server error: ${error.message}`, 'error');
throw error; // Re-throw to allow calling function to handle
}
}
// --- UI Rendering ---
function showLoadingScreen() {
loadingEl.style.display = 'flex';
errorViewEl.style.display = 'none';
appContentEl.style.display = 'none';
}
function showError(message) {
loadingEl.style.display = 'none';
errorViewEl.innerHTML = `<h2>Error</h2><p>${message}</p><button class='btn' onclick='window.location.reload()'>Reload App</button>`;
errorViewEl.style.display = 'flex';
appContentEl.style.display = 'none';
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
}
function showAppContent() {
loadingEl.style.display = 'none';
errorViewEl.style.display = 'none';
appContentEl.style.display = 'flex';
}
let flashTimeout;
function showFlash(message, type = 'success') {
clearTimeout(flashTimeout); // Clear existing timeout if any
const flashDiv = document.createElement('div');
flashDiv.className = `flash ${type}`;
flashDiv.textContent = message;
flashDiv.onclick = () => { // Allow dismissing by clicking
flashDiv.style.opacity = '0';
setTimeout(() => {
if (flashDiv.parentNode === flashContainerEl) {
flashContainerEl.removeChild(flashDiv);
}
}, 300);
};
flashContainerEl.innerHTML = ''; // Clear previous messages
flashContainerEl.appendChild(flashDiv);
// Trigger fade in
requestAnimationFrame(() => {
flashDiv.classList.add('show');
});
flashTimeout = setTimeout(() => {
flashDiv.style.opacity = '0';
setTimeout(() => {
if (flashDiv.parentNode === flashContainerEl) {
flashContainerEl.removeChild(flashDiv);
}
}, 300); // Wait for fade out transition
}, 5000);
if (type === 'success' && tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('success');
if (type === 'error' && tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
}
function renderBreadcrumbs(breadcrumbs) {
breadcrumbsContainerEl.innerHTML = '';
if (!breadcrumbs || breadcrumbs.length === 0) return;
breadcrumbs.forEach((crumb, index) => {
if (index > 0) {
const separator = document.createElement('span');
separator.className = 'separator';
separator.textContent = '›'; // iOS style separator
breadcrumbsContainerEl.appendChild(separator);
}
if (index === breadcrumbs.length - 1) {
const span = document.createElement('span');
span.className = 'current-folder';
span.textContent = crumb.name;
breadcrumbsContainerEl.appendChild(span);
currentFolderTitleEl.textContent = crumb.name === 'Root' ? 'Files' : crumb.name; // Set section title
} else {
const link = document.createElement('a');
link.href = '#';
link.textContent = crumb.name;
link.onclick = (e) => { e.preventDefault(); loadFolderContent(crumb.id); };
breadcrumbsContainerEl.appendChild(link);
}
});
// Show/hide back button
if (breadcrumbs.length > 1) {
tg.BackButton.show();
} else {
tg.BackButton.hide();
}
}
function getItemIcon(item) {
if (item.type === 'folder') return '📁';
switch (item.file_type) {
case 'image': return '🖼️'; // Or use preview directly
case 'video': return '▶️';
case 'pdf': return '📄';
case 'text': return '📝';
case 'doc': return '📄'; // Could use a specific Word icon if available
case 'sheet': return '📊';
case 'slides': return '🖥️';
case 'archive': return '📦';
case 'audio': return '🎵';
default: return '❓';
}
}
function renderItems(items) {
fileGridContainerEl.innerHTML = ''; // Clear previous items
if (!items || items.length === 0) {
emptyFolderPlaceholder.style.display = 'block';
fileGridContainerEl.style.display = 'none';
return;
}
emptyFolderPlaceholder.style.display = 'none';
fileGridContainerEl.style.display = 'grid';
// Sort: folders first, then alphabetically by name
items.sort((a, b) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
const nameA = a.name || a.original_filename || '';
const nameB = b.name || b.original_filename || '';
return nameA.localeCompare(nameB);
});
items.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.className = `item ${item.type}` + (item.type === 'file' ? ` type-${item.file_type}` : '');
const filenameDisplay = item.original_filename || item.name || 'Unnamed';
let previewHtml = '';
let mainAction = () => {}; // Function to execute on primary tap
const previewContainer = document.createElement('div');
previewContainer.className = 'item-preview-container';
if (item.type === 'folder') {
previewContainer.innerHTML = `<span class="item-icon">${getItemIcon(item)}</span>`;
mainAction = () => loadFolderContent(item.id);
} else { // File
const iconHtml = `<span class="item-icon">${getItemIcon(item)}</span>`;
const dlUrl = `${apiBaseUrl}/download/${item.id}`;
const previewUrl = `${apiBaseUrl}/preview_thumb/${item.id}`;
const textContentUrl = `${apiBaseUrl}/get_text_content/${item.id}`;
if (item.file_type === 'image') {
previewContainer.innerHTML = `<img class="item-preview" src="${previewUrl}" alt="${filenameDisplay}" loading="lazy" onerror="this.style.display='none'; this.parentElement.innerHTML = '${iconHtml}'">`;
mainAction = () => openModal(dlUrl, 'image', item.id);
} else if (item.file_type === 'video') {
previewContainer.innerHTML = iconHtml; // Show icon initially
mainAction = () => openModal(dlUrl, 'video', item.id);
} else if (item.file_type === 'pdf') {
previewContainer.innerHTML = iconHtml;
mainAction = () => openModal(dlUrl, 'pdf', item.id);
} else if (item.file_type === 'text') {
previewContainer.innerHTML = iconHtml;
mainAction = () => openModal(textContentUrl, 'text', item.id);
} else {
previewContainer.innerHTML = iconHtml;
mainAction = () => { showFlash('Preview not available for this file type.', 'error'); if(tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light'); }; // Maybe trigger download?
}
}
previewContainer.onclick = mainAction;
itemDiv.appendChild(previewContainer);
// Info section
const infoDiv = document.createElement('div');
infoDiv.className = 'item-info';
infoDiv.innerHTML = `
<p class="filename" title="${filenameDisplay}">${filenameDisplay}</p>
${item.upload_date ? `<p class="details">${item.upload_date.split(' ')[0]}</p>` : '<p class="details">&nbsp;</p>'}
`;
itemDiv.appendChild(infoDiv);
// Actions Trigger (ellipsis button)
const trigger = document.createElement('div');
trigger.className = 'item-actions-trigger';
trigger.innerHTML = '...'; // Or use an SVG icon
trigger.onclick = (e) => {
e.stopPropagation(); // Prevent triggering mainAction
openActionsMenu(item);
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
};
previewContainer.appendChild(trigger); // Append trigger to preview container
fileGridContainerEl.appendChild(itemDiv);
});
}
// --- Actions Menu ---
function openActionsMenu(item) {
actionsMenuTitle.textContent = item.name || item.original_filename || 'Item';
actionsMenuButtons.innerHTML = ''; // Clear previous buttons
const filenameDisplay = item.original_filename || item.name || 'Unnamed';
if (item.type === 'folder') {
addButtonToActionMenu('Open', () => loadFolderContent(item.id), 'btn');
addButtonToActionMenu('Delete', () => deleteFolder(item.id, filenameDisplay), 'btn delete');
} else { // File
const dlUrl = `${apiBaseUrl}/download/${item.id}`;
const textContentUrl = `${apiBaseUrl}/get_text_content/${item.id}`;
const previewable = ['image', 'video', 'pdf', 'text'].includes(item.file_type);
if (previewable) {
let previewAction = () => {};
if (item.file_type === 'image') previewAction = () => openModal(dlUrl, 'image', item.id);
else if (item.file_type === 'video') previewAction = () => openModal(dlUrl, 'video', item.id);
else if (item.file_type === 'pdf') previewAction = () => openModal(dlUrl, 'pdf', item.id);
else if (item.file_type === 'text') previewAction = () => openModal(textContentUrl, 'text', item.id);
addButtonToActionMenu('Preview', previewAction, 'btn');
}
addButtonToActionMenu('Download', () => window.open(dlUrl, '_blank'), 'btn'); // Open in new tab for download
addButtonToActionMenu('Delete', () => deleteFile(item.id, filenameDisplay), 'btn delete');
}
actionsMenu.style.display = 'block';
actionsMenuBackdrop.style.display = 'block';
}
function addButtonToActionMenu(text, onClickAction, className = 'btn') {
const button = document.createElement('button');
button.className = className;
button.textContent = text;
button.onclick = () => {
closeActionsMenu();
onClickAction();
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
};
actionsMenuButtons.appendChild(button);
}
function closeActionsMenu() {
// Add closing animation if desired
actionsMenu.style.display = 'none';
actionsMenuBackdrop.style.display = 'none';
}
// --- Modal Logic ---
async function openModal(srcOrUrl, type, itemId) {
modalContent.innerHTML = '<p>Loading...</p>'; // Show loading state
mediaModal.style.display = 'flex';
// Trigger fade-in animation
requestAnimationFrame(() => {
mediaModal.classList.add('show');
});
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
try {
// Construct absolute URL if needed
const absoluteUrl = srcOrUrl.startsWith('/') ? window.location.origin + srcOrUrl : srcOrUrl;
if (type === 'pdf') {
// Use Telegram's built-in viewer or a reliable external one
// Option 1: Telegram's viewer (preferred)
if (tg.openLink) {
tg.openLink(absoluteUrl);
closeModalManual(); // Close our modal as TG will handle it
return;
} else {
// Fallback: Google Docs viewer in iframe (might be blocked)
modalContent.innerHTML = `<iframe src="https://docs.google.com/gview?url=${encodeURIComponent(absoluteUrl)}&embedded=true" title="PDF Viewer"></iframe>`;
}
} else if (type === 'image') {
modalContent.innerHTML = `<img src="${absoluteUrl}" alt="Image Preview">`;
} else if (type === 'video') {
modalContent.innerHTML = `<video controls autoplay style='width: 100%; height: auto; max-height: 100%;'><source src="${absoluteUrl}">Your browser does not support the video tag.</video>`;
} else if (type === 'text') {
// Fetch text content using apiCall or standard fetch
const response = await fetch(absoluteUrl); // Use the dedicated text route URL directly
if (!response.ok) throw new Error(`Failed to load text content: ${response.statusText}`);
const text = await response.text();
const escapedText = text.replace(/</g, "&lt;").replace(/>/g, "&gt;"); // Basic HTML escaping
modalContent.innerHTML = `<pre>${escapedText}</pre>`;
} else {
modalContent.innerHTML = '<p>Preview not supported for this file type.</p>';
}
} catch (error) {
console.error("Error loading modal content:", error);
modalContent.innerHTML = `<p>Could not load preview. ${error.message}</p>`;
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
}
}
function closeModalManual() {
mediaModal.classList.remove('show'); // Trigger fade-out
setTimeout(() => {
mediaModal.style.display = 'none';
const video = mediaModal.querySelector('video');
if (video) { video.pause(); video.src = ''; }
const iframe = mediaModal.querySelector('iframe');
if (iframe) iframe.src = 'about:blank';
modalContent.innerHTML = ''; // Clear content after fade out
}, 300); // Match CSS transition duration
}
// --- Folder Operations ---
async function loadFolderContent(folderId) {
currentFolderId = folderId;
console.log(`Loading folder: ${folderId}`);
// Optionally show a loading indicator over the grid
fileGridContainerEl.innerHTML = '<p style="text-align: center; color: var(--tg-theme-hint-color);">Loading...</p>'; // Simple text indicator
emptyFolderPlaceholder.style.display = 'none';
fileGridContainerEl.style.display = 'block'; // Ensure grid container is visible for loading message
try {
const data = await apiCall('/get_dashboard_data', 'POST', { folder_id: folderId });
currentItems = data.items || [];
renderBreadcrumbs(data.breadcrumbs || [{'id': 'root', 'name': 'Root'}]);
renderItems(currentItems);
} catch (error) {
// Error is already handled and shown by apiCall
fileGridContainerEl.innerHTML = '<p style="text-align: center; color: var(--delete-color);">Failed to load folder content.</p>';
}
}
async function handleCreateFolder() {
const folderName = newFolderInput.value.trim();
if (!folderName) {
showFlash('Please enter a folder name.', 'error');
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
return;
}
// Basic validation for problematic characters
if (/[<>:"/\\|?*]/.test(folderName)) {
showFlash('Folder name contains invalid characters.', 'error');
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
return;
}
createFolderBtn.disabled = true;
createFolderBtn.textContent = 'Creating...';
try {
await apiCall('/create_folder', 'POST', {
parent_folder_id: currentFolderId,
folder_name: folderName
});
showFlash(`Folder "${folderName}" created.`, 'success');
newFolderInput.value = '';
loadFolderContent(currentFolderId); // Refresh content
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('success');
} catch (error) {
// Error handled by apiCall, flash message already shown
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
} finally {
createFolderBtn.disabled = false;
createFolderBtn.textContent = 'Create';
}
}
async function deleteFolder(folderId, folderName) {
tg.showConfirm(`Delete the folder "${folderName}"? It must be empty.`, async (confirmed) => {
if (confirmed) {
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('medium');
try {
await apiCall(`/delete_folder/${folderId}`, 'POST', { current_folder_id: currentFolderId }); // Pass current folder for context if needed by backend
showFlash(`Folder "${folderName}" deleted.`, 'success');
loadFolderContent(currentFolderId); // Refresh
} catch (error) {
// Error handled by apiCall
}
} else {
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
}
});
}
async function deleteFile(fileId, fileName) {
tg.showConfirm(`Delete the file "${fileName}"?`, async (confirmed) => {
if (confirmed) {
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('medium');
try {
await apiCall(`/delete_file/${fileId}`, 'POST', { current_folder_id: currentFolderId }); // Pass current folder for context
showFlash(`File "${fileName}" deleted.`, 'success');
loadFolderContent(currentFolderId); // Refresh
} catch (error) {
// Error handled by apiCall
}
} else {
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
}
});
}
// --- File Upload ---
function handleFileSelection() {
const files = fileInput.files;
if (files.length === 0) return;
if (files.length > 20) {
showFlash('Maximum 20 files per upload.', 'error');
fileInput.value = ''; // Clear selection
return;
}
// Immediately start upload process
uploadFiles(files);
}
function uploadFiles(files) {
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
// Disable upload button/trigger? Maybe not necessary if we hide the input
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
formData.append('current_folder_id', currentFolderId);
// initData is added by apiCall wrapper
apiCall('/upload', 'POST', formData, true)
.then(data => {
showFlash(data.message || `${files.length} file(s) uploaded successfully.`, 'success');
loadFolderContent(currentFolderId); // Refresh list
})
.catch(error => {
// Flash message shown by apiCall wrapper
console.error("Upload failed:", error);
})
.finally(() => {
progressContainer.style.display = 'none';
fileInput.value = ''; // Clear selection after attempt
});
// Note: Using fetch directly won't give progress updates easily.
// If progress is essential, revert to XMLHttpRequest as in the original code,
// but wrap it carefully to handle auth (initData) and base URL.
// For simplicity and consistency with apiCall, we sacrifice progress here.
// Let's add it back using XHR for a better UX.
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', function(event) {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
progressBar.style.width = percentComplete + '%';
// Update any visual indicator if needed
}
});
xhr.addEventListener('load', function() {
progressContainer.style.display = 'none';
fileInput.value = ''; // Clear selection
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
if (data.status === 'ok') {
showFlash(data.message || `${files.length} file(s) uploaded.`, 'success');
loadFolderContent(currentFolderId); // Refresh
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('success');
} else {
showFlash(data.message || 'Server error during upload processing.', 'error');
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
}
} catch (e) {
showFlash('Invalid server response after upload.', 'error');
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
}
} else {
showFlash(`Upload failed: ${xhr.statusText || xhr.status}`, 'error');
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
}
});
xhr.addEventListener('error', function() {
showFlash('Network error during upload.', 'error');
progressContainer.style.display = 'none';
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
});
xhr.addEventListener('abort', function() {
showFlash('Upload cancelled.', 'error');
progressContainer.style.display = 'none';
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
});
xhr.open('POST', apiBaseUrl + '/upload', true);
// Add initData to FormData *before* sending with XHR
formData.append('initData', validatedInitData);
xhr.send(formData);
}
// --- Initialization ---
function initializeApp() {
showLoadingScreen();
applyTheme(); // Apply theme early
apiBaseUrl = window.location.origin; // Set base URL
tg.onEvent('themeChanged', applyTheme); // Listen for theme changes
if (!tg.initData) {
showError("Telegram initialization data (initData) is missing. Please try restarting the Mini App inside Telegram.");
return;
}
console.log("initData received:", tg.initData.substring(0, 100) + "...");
validatedInitData = tg.initData; // Store it globally
fetch(apiBaseUrl + '/validate_init_data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: validatedInitData })
})
.then(response => {
if (!response.ok) {
return response.json().then(err => { throw new Error(err.message || `Validation failed with status ${response.status}`); });
}
return response.json();
})
.then(data => {
if (data.status === 'ok' && data.user) {
currentUser = data.user;
let userName = currentUser.first_name || '';
if (currentUser.last_name) userName += ' ' + currentUser.last_name;
if (!userName && currentUser.username) userName = '@' + currentUser.username;
if (!userName) userName = `ID: ${currentUser.id}`;
userInfoHeaderEl.textContent = `User: ${userName}`;
showAppContent();
loadFolderContent('root'); // Load root folder
} else {
throw new Error(data.message || 'User validation failed.');
}
})
.catch(error => {
console.error("Initialization or Validation failed:", error);
showError(`Authorization Error: ${error.message}. Please reload the app.`);
validatedInitData = null; // Invalidate data on failure
});
// Event Listeners
createFolderBtn.addEventListener('click', handleCreateFolder);
fileInput.addEventListener('change', handleFileSelection);
// Back Button Handler
tg.BackButton.onClick(() => {
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
const currentPath = getCurrentBreadcrumbPath();
if (currentPath.length > 1) {
const parentFolder = currentPath[currentPath.length - 2];
loadFolderContent(parentFolder.id);
} else {
// At root, maybe close the app?
// tg.close();
}
});
}
// Helper to get current path from rendered breadcrumbs
function getCurrentBreadcrumbPath() {
const path = [];
breadcrumbsContainerEl.querySelectorAll('a, span.current-folder').forEach(el => {
if (el.tagName === 'A') {
// Extract ID from onclick handler (simplistic)
const onclickAttr = el.getAttribute('onclick');
const match = onclickAttr ? onclickAttr.match(/loadFolderContent\('([^']+)'\)/) : null;
if (match && match[1]) {
path.push({ id: match[1], name: el.textContent });
}
} else if (el.classList.contains('current-folder')) {
// The current folder ID is stored globally
path.push({ id: currentFolderId, name: el.textContent });
}
});
return path;
}
// --- Start the App ---
initializeApp();
</script>
</body>
</html>
"""
@app.route('/')
def index():
return Response(HTML_TEMPLATE, mimetype='text/html')
@app.route('/validate_init_data', methods=['POST'])
def validate_init_data():
data = request.get_json()
if not data or 'initData' not in data:
return jsonify({"status": "error", "message": "Missing initData"}), 400
init_data = data['initData']
user_info = check_telegram_authorization(init_data, BOT_TOKEN)
if user_info and 'id' in user_info:
tg_user_id = str(user_info['id'])
db_data = load_data()
users = db_data.setdefault('users', {})
save_needed = False
user_entry = users.get(tg_user_id)
if not user_entry or not isinstance(user_entry, dict):
logging.info(f"New user detected or invalid entry: {tg_user_id}. Initializing.")
users[tg_user_id] = {
'user_info': user_info,
'created_at': datetime.now().isoformat() # Use ISO format
}
initialize_user_filesystem(users[tg_user_id])
save_needed = True
else:
# Check if filesystem needs initialization or repair
if 'filesystem' not in user_entry or not isinstance(user_entry.get('filesystem'), dict):
logging.warning(f"Filesystem missing or invalid for user {tg_user_id}. Re-initializing.")
initialize_user_filesystem(user_entry)
save_needed = True
# Optionally update user info if changed (e.g., username)
if user_entry.get('user_info', {}).get('username') != user_info.get('username'):
user_entry['user_info'] = user_info # Update stored info
save_needed = True
if save_needed:
if not save_data(db_data):
logging.error(f"Failed to save data for user {tg_user_id} during validation.")
# Avoid returning 500 if possible, user might still be usable with loaded data
# return jsonify({"status": "error", "message": "Error saving user data."}), 500
pass # Logged the error, proceed with current (possibly unsaved) state
return jsonify({"status": "ok", "user": user_info})
else:
logging.warning(f"Validation failed for initData prefix: {init_data[:100]}...")
return jsonify({"status": "error", "message": "Invalid authorization data."}), 403
@app.route('/get_dashboard_data', methods=['POST'])
def get_dashboard_data():
data = request.get_json()
if not data or 'initData' not in data or 'folder_id' not in data:
return jsonify({"status": "error", "message": "Incomplete request"}), 400
user_info = check_telegram_authorization(data['initData'], BOT_TOKEN)
if not user_info or 'id' not in user_info:
return jsonify({"status": "error", "message": "Unauthorized"}), 403
tg_user_id = str(user_info['id'])
folder_id = data['folder_id']
db_data = load_data()
user_data = db_data.get('users', {}).get(tg_user_id)
if not user_data or 'filesystem' not in user_data or not isinstance(user_data['filesystem'], dict):
logging.error(f"User data or filesystem missing/invalid for validated user {tg_user_id}")
# Attempt recovery if filesystem is bad but user_data exists
if isinstance(user_data, dict):
logging.warning(f"Attempting to re-initialize filesystem for user {tg_user_id}")
initialize_user_filesystem(user_data)
if not save_data(db_data):
logging.error(f"Failed to save re-initialized filesystem for user {tg_user_id}")
# Continue with the newly initialized filesystem if save failed but init worked
else:
return jsonify({"status": "error", "message": "User data error"}), 500
current_folder, _ = find_node_by_id(user_data['filesystem'], folder_id)
if not current_folder or current_folder.get('type') != 'folder':
logging.warning(f"Folder {folder_id} not found or invalid for user {tg_user_id}. Defaulting to root.")
folder_id = 'root'
current_folder, _ = find_node_by_id(user_data['filesystem'], folder_id)
if not current_folder:
logging.critical(f"CRITICAL: Root folder cannot be found for user {tg_user_id} even after check.")
# Attempt recovery again
initialize_user_filesystem(user_data)
if not save_data(db_data):
logging.error(f"Failed to save re-initialized filesystem after root recovery attempt for {tg_user_id}")
current_folder, _ = find_node_by_id(user_data['filesystem'], 'root')
if not current_folder: # Still failing
return jsonify({"status": "error", "message": "Critical error: Root folder missing."}), 500
items_in_folder = current_folder.get('children', [])
if not isinstance(items_in_folder, list):
logging.warning(f"Invalid 'children' in folder {folder_id} for user {tg_user_id}. Resetting to empty list.")
items_in_folder = []
current_folder['children'] = []
# Consider saving data here if you want to persist this fix immediately
# save_data(db_data)
breadcrumbs = get_node_path_list(user_data['filesystem'], folder_id)
current_folder_info = {
'id': current_folder.get('id'),
'name': current_folder.get('name', 'Root')
}
return jsonify({
"status": "ok",
"items": items_in_folder,
"breadcrumbs": breadcrumbs,
"current_folder": current_folder_info
})
@app.route('/upload', methods=['POST'])
def upload_files():
init_data = request.form.get('initData')
current_folder_id = request.form.get('current_folder_id', 'root')
files = request.files.getlist('files')
user_info = check_telegram_authorization(init_data, BOT_TOKEN)
if not user_info or 'id' not in user_info:
return jsonify({"status": "error", "message": "Unauthorized"}), 403
tg_user_id = str(user_info['id'])
if not HF_TOKEN_WRITE:
return jsonify({'status': 'error', 'message': 'Upload configuration error.'}), 500
if not files or all(not f.filename for f in files):
return jsonify({'status': 'error', 'message': 'No files selected for upload.'}), 400
if len(files) > 20:
return jsonify({'status': 'error', 'message': 'Maximum 20 files per upload.'}), 400
db_data = load_data()
user_data = db_data.get('users', {}).get(tg_user_id)
if not user_data or 'filesystem' not in user_data or not isinstance(user_data['filesystem'], dict):
logging.error(f"Upload error: User data or filesystem missing/invalid for {tg_user_id}")
return jsonify({"status": "error", "message": "User data error during upload."}), 500
target_folder_node, _ = find_node_by_id(user_data['filesystem'], current_folder_id)
if not target_folder_node or target_folder_node.get('type') != 'folder':
logging.error(f"Upload error: Target folder {current_folder_id} not found for user {tg_user_id}")
return jsonify({'status': 'error', 'message': 'Target folder not found!'}), 404
api = HfApi()
uploaded_count = 0
errors = []
nodes_added = [] # Keep track of nodes added in this request
for file in files:
if file and file.filename:
original_filename = secure_filename(file.filename)
if not original_filename:
logging.warning(f"Skipping file with potentially insecure name: {file.filename}")
errors.append(f"Skipped file with invalid name: {file.filename}")
continue
name_part, ext_part = os.path.splitext(original_filename)
unique_suffix = uuid.uuid4().hex[:8]
# Ensure filename doesn't become excessively long
max_len = 100
safe_name_part = name_part[:max_len]
unique_filename = f"{safe_name_part}_{unique_suffix}{ext_part}"
file_id = uuid.uuid4().hex
# Define path relative to user/folder for organization
hf_path = f"cloud_files/{tg_user_id}/{file_id[:2]}/{file_id}_{unique_filename}" # Add subfolder based on ID start
temp_path = os.path.join(UPLOAD_FOLDER, f"{file_id}_{unique_filename}")
file_info = {
'type': 'file', 'id': file_id,
'original_filename': original_filename,
'unique_filename': unique_filename, # Store the unique name used on HF
'path': hf_path,
'file_type': get_file_type(original_filename),
'upload_date': datetime.now().isoformat() # Use ISO format
}
try:
file.save(temp_path)
logging.info(f"Attempting HF upload to: {hf_path}")
api.upload_file(
path_or_fileobj=temp_path, path_in_repo=hf_path,
repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
commit_message=f"User {tg_user_id} uploaded {original_filename}"
)
logging.info(f"HF upload successful for {original_filename} ({file_id})")
# Add node to filesystem structure *after* successful HF upload
if add_node(user_data['filesystem'], current_folder_id, file_info):
uploaded_count += 1
nodes_added.append(file_info) # Track success
else:
# This case is critical - file is on HF, but not in DB structure
error_msg = f"Failed to add metadata for {original_filename} after upload."
errors.append(error_msg)
logging.error(f"{error_msg} User: {tg_user_id}, FileID: {file_id}, TargetFolder: {current_folder_id}")
# Attempt to delete the orphaned HF file
try:
logging.warning(f"Attempting cleanup of orphaned HF file: {hf_path}")
api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
logging.info(f"Orphaned file {hf_path} deleted from HF.")
except Exception as del_err:
logging.error(f"CRITICAL: Failed to delete orphaned HF file {hf_path}: {del_err}")
except Exception as e:
logging.error(f"Upload error for {original_filename} (User: {tg_user_id}, FileID: {file_id}): {e}", exc_info=True)
errors.append(f"Error uploading {original_filename}")
# Ensure node wasn't partially added if error occurred during add_node or before
if file_info in nodes_added: nodes_added.remove(file_info)
finally:
# Clean up local temporary file
if os.path.exists(temp_path):
try: os.remove(temp_path)
except OSError as e_rm: logging.warning(f"Error removing temp file {temp_path}: {e_rm}")
# Save data only if at least one file was successfully uploaded AND added to structure
if uploaded_count > 0 and nodes_added:
logging.info(f"Saving DB for user {tg_user_id} after {uploaded_count} successful uploads.")
if not save_data(db_data):
# If save fails, we have inconsistency: files on HF, maybe some nodes added in memory, but not persisted.
logging.error(f"CRITICAL: Failed to save DB after successful uploads for user {tg_user_id}.")
errors.append("Critical error saving file metadata after upload.")
# Attempt to revert the in-memory additions? Very complex. Logging is key here.
# Rollback: Remove nodes that were added in this request from the in-memory structure
for node_info in nodes_added:
remove_node(user_data['filesystem'], node_info['id'])
uploaded_count = 0 # Reflect that the save failed
# Do NOT try to delete the HF files here, could lead to data loss if DB save fails intermittently
final_message = f"{uploaded_count} file(s) uploaded."
if errors:
final_message += f" Errors occurred with {len(errors)} file(s)."
# Consider logging the specific errors to the user if appropriate
# final_message += " Details: " + "; ".join(errors)
return jsonify({
"status": "ok" if uploaded_count > 0 else "error", # Status based on successful *persisted* uploads
"message": final_message
})
@app.route('/create_folder', methods=['POST'])
def create_folder():
data = request.get_json()
if not data or 'initData' not in data or 'parent_folder_id' not in data or 'folder_name' not in data:
return jsonify({"status": "error", "message": "Incomplete request"}), 400
user_info = check_telegram_authorization(data['initData'], BOT_TOKEN)
if not user_info or 'id' not in user_info:
return jsonify({"status": "error", "message": "Unauthorized"}), 403
tg_user_id = str(user_info['id'])
parent_folder_id = data['parent_folder_id']
folder_name = data['folder_name'].strip()
if not folder_name:
return jsonify({'status': 'error', 'message': 'Folder name cannot be empty.'}), 400
if len(folder_name) > 100:
return jsonify({'status': 'error', 'message': 'Folder name is too long.'}), 400
# Basic validation for problematic characters
if /[<>:"/\\|?*]/.test(folder_name):
return jsonify({'status': 'error', 'message': 'Folder name contains invalid characters.'}), 400
db_data = load_data()
user_data = db_data.get('users', {}).get(tg_user_id)
if not user_data or 'filesystem' not in user_data or not isinstance(user_data['filesystem'], dict):
logging.error(f"Create folder error: User data or filesystem missing/invalid for {tg_user_id}")
return jsonify({"status": "error", "message": "User data error."}), 500
# Check if folder with the same name already exists in the parent
parent_node, _ = find_node_by_id(user_data['filesystem'], parent_folder_id)
if parent_node and 'children' in parent_node and isinstance(parent_node['children'], list):
for child in parent_node['children']:
if isinstance(child, dict) and child.get('type') == 'folder' and child.get('name') == folder_name:
return jsonify({'status': 'error', 'message': f'A folder named "{folder_name}" already exists here.'}), 409 # 409 Conflict
folder_id = uuid.uuid4().hex
folder_data = {
'type': 'folder', 'id': folder_id,
'name': folder_name, 'children': []
}
if add_node(user_data['filesystem'], parent_folder_id, folder_data):
if save_data(db_data):
return jsonify({'status': 'ok', 'message': f'Folder "{folder_name}" created.'})
else:
logging.error(f"Create folder save error ({tg_user_id}) after adding node {folder_id}.")
# Attempt to rollback the in-memory addition
remove_node(user_data['filesystem'], folder_id)
return jsonify({'status': 'error', 'message': 'Error saving data after creating folder.'}), 500
else:
# This implies parent folder wasn't found or wasn't a folder type
logging.error(f"Create folder error: Failed add_node. User: {tg_user_id}, Parent: {parent_folder_id}")
return jsonify({'status': 'error', 'message': 'Could not find parent folder to add new folder.'}), 400
@app.route('/download/<file_id>')
def download_file_route(file_id):
# Note: This route has NO BUILT-IN AUTHENTICATION.
# It relies on the obscurity of file_id and HF path.
# For sensitive data, proper auth (e.g., checking initData passed as query param,
# or session-based auth) would be needed here, which complicates direct linking/previewing.
db_data = load_data() # Use cached data if possible
file_node = None
owner_user_id = None
# Find the file node across all users
for user_id_scan, user_data_scan in db_data.get('users', {}).items():
if 'filesystem' in user_data_scan and isinstance(user_data_scan['filesystem'], dict):
node, _ = find_node_by_id(user_data_scan['filesystem'], file_id)
if node and isinstance(node, dict) and node.get('type') == 'file':
file_node = node
owner_user_id = user_id_scan
break
if not file_node:
logging.warning(f"Download request for unknown file_id: {file_id}")
return Response("File not found", status=404, mimetype='text/plain')
hf_path = file_node.get('path')
original_filename = file_node.get('original_filename', f'{file_id}_download')
if not hf_path:
logging.error(f"Download error: Missing HF path for file ID {file_id} (Owner: {owner_user_id})")
return Response("Error: File path configuration missing", status=500, mimetype='text/plain')
# Construct the direct download URL
# Using /info/refs might be faster for checking existence before redirecting, but resolve/main is simpler
file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}?download=true"
logging.info(f"Attempting to serve file via redirect/proxy from: {file_url}")
try:
headers = {}
if HF_TOKEN_READ:
headers["authorization"] = f"Bearer {HF_TOKEN_READ}"
# Use requests to stream the file from HF
# Timeout set for initial connection and read chunks
response = requests.get(file_url, headers=headers, stream=True, timeout=(10, 30)) # (connect_timeout, read_timeout)
response.raise_for_status() # Check for 4xx/5xx errors from HF
# Prepare Flask response headers
resp_headers = {}
content_type = response.headers.get('Content-Type', 'application/octet-stream')
resp_headers['Content-Type'] = content_type
# Create a safe filename for Content-Disposition
# Simple approach: replace potentially problematic chars
safe_filename = "".join(c if c.isalnum() or c in ['.', '-', '_'] else '_' for c in original_filename)
# Encode for header value (URL encoding for filename*=UTF-8'')
encoded_filename = urlencode({'filename': original_filename}, encoding='utf-8')[9:]
resp_headers['Content-Disposition'] = f"attachment; filename=\"{safe_filename}\"; filename*=UTF-8''{encoded_filename}"
# Add Content-Length if provided by HF
if 'Content-Length' in response.headers:
resp_headers['Content-Length'] = response.headers['Content-Length']
# Stream the response body
return Response(response.iter_content(chunk_size=8192), status=response.status_code, headers=resp_headers)
except requests.exceptions.Timeout:
logging.error(f"Timeout downloading file from HF: {hf_path}")
return Response("Error: Timed out connecting to file storage", status=504, mimetype='text/plain') # 504 Gateway Timeout
except requests.exceptions.RequestException as e:
status_code = e.response.status_code if e.response is not None else 502 # 502 Bad Gateway if no response
logging.error(f"Error downloading file from HF ({hf_path}, Owner: {owner_user_id}): {e} (Status: {status_code})")
# Don't expose detailed error message to client
return Response(f"Error retrieving file ({status_code})", status=status_code, mimetype='text/plain')
except Exception as e:
logging.error(f"Unexpected error during download proxy ({hf_path}, Owner: {owner_user_id}): {e}", exc_info=True)
return Response("Internal server error during file download", status=500, mimetype='text/plain')
@app.route('/delete_file/<file_id>', methods=['POST'])
def delete_file_route(file_id):
data = request.get_json()
if not data or 'initData' not in data: # current_folder_id might not be strictly necessary
return jsonify({"status": "error", "message": "Incomplete request"}), 400
user_info = check_telegram_authorization(data['initData'], BOT_TOKEN)
if not user_info or 'id' not in user_info:
return jsonify({"status": "error", "message": "Unauthorized"}), 403
tg_user_id = str(user_info['id'])
if not HF_TOKEN_WRITE:
return jsonify({'status': 'error', 'message': 'Deletion configuration error.'}), 500
db_data = load_data()
user_data = db_data.get('users', {}).get(tg_user_id)
if not user_data or 'filesystem' not in user_data or not isinstance(user_data['filesystem'], dict):
logging.error(f"Delete file error: User data or filesystem missing/invalid for {tg_user_id}")
# Don't reveal file existence, just say user data error
return jsonify({"status": "error", "message": "User data error."}), 500
file_node, parent_node = find_node_by_id(user_data['filesystem'], file_id)
if not file_node or file_node.get('type') != 'file' or not parent_node:
# File not found *for this user*. Do not confirm non-existence.
logging.warning(f"Delete request for non-existent/invalid file ID {file_id} by user {tg_user_id}")
return jsonify({'status': 'error', 'message': 'File not found.'}), 404
hf_path = file_node.get('path')
original_filename = file_node.get('original_filename', 'file')
db_removed = False
hf_deleted = False
save_error = False
# 1. Attempt to delete from Hugging Face Hub
if hf_path:
try:
api = HfApi()
logging.info(f"Attempting HF delete for: {hf_path} by user {tg_user_id}")
api.delete_file(
path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
commit_message=f"User {tg_user_id} deleted {original_filename}"
)
hf_deleted = True
logging.info(f"Successfully deleted file {hf_path} from HF Hub for user {tg_user_id}")
except hf_utils.EntryNotFoundError:
logging.warning(f"File {hf_path} already deleted or never existed on HF Hub for delete attempt by {tg_user_id}.")
hf_deleted = True # Treat as success for the purpose of DB removal
except Exception as e:
logging.error(f"Error deleting file from HF Hub ({hf_path}, User: {tg_user_id}): {e}")
# Do not stop here; still try to remove from DB if HF delete fails,
# but report the overall operation as potentially failed.
# A background cleanup job might be needed for such inconsistencies.
else:
logging.warning(f"File node {file_id} for user {tg_user_id} has no HF path. Skipping HF deletion.")
hf_deleted = True # No path means nothing to delete on HF
# 2. Attempt to remove from DB structure *if HF deletion was successful or skipped*
if hf_deleted:
if remove_node(user_data['filesystem'], file_id):
db_removed = True
logging.info(f"Removed file node {file_id} from DB for user {tg_user_id}")
# 3. Attempt to save the updated DB structure
if not save_data(db_data):
logging.error(f"CRITICAL: Delete file DB save error for user {tg_user_id} after removing node {file_id}.")
save_error = True
# Attempt to rollback the in-memory removal? Very risky. Better to log.
# Re-adding the node might fail if parent was modified etc.
# add_node(user_data['filesystem'], parent_node['id'], file_node) # Risky rollback attempt
else:
# This shouldn't happen if find_node_by_id found it initially
logging.error(f"Failed to remove file node {file_id} from DB structure for {tg_user_id} after it was found.")
# Determine final status
if db_removed and not save_error:
return jsonify({'status': 'ok', 'message': f'File "{original_filename}" deleted.'})
elif hf_deleted and db_removed and save_error:
return jsonify({'status': 'error', 'message': f'File deleted from storage, but failed to update database.'}), 500
elif hf_deleted and not db_removed:
return jsonify({'status': 'error', 'message': f'File deleted from storage, but failed to remove from database structure.'}), 500
else: # hf_deleted is False (meaning HF delete failed)
return jsonify({'status': 'error', 'message': f'Failed to delete file from storage.'}), 500
@app.route('/delete_folder/<folder_id>', methods=['POST'])
def delete_folder_route(folder_id):
if folder_id == 'root':
return jsonify({'status': 'error', 'message': 'Cannot delete the root folder.'}), 400
data = request.get_json()
if not data or 'initData' not in data:
return jsonify({"status": "error", "message": "Incomplete request"}), 400
user_info = check_telegram_authorization(data['initData'], BOT_TOKEN)
if not user_info or 'id' not in user_info:
return jsonify({"status": "error", "message": "Unauthorized"}), 403
tg_user_id = str(user_info['id'])
db_data = load_data()
user_data = db_data.get('users', {}).get(tg_user_id)
if not user_data or 'filesystem' not in user_data or not isinstance(user_data['filesystem'], dict):
logging.error(f"Delete folder error: User data or filesystem missing/invalid for {tg_user_id}")
return jsonify({"status": "error", "message": "User data error."}), 500
folder_node, parent_node = find_node_by_id(user_data['filesystem'], folder_id)
if not folder_node or folder_node.get('type') != 'folder' or not parent_node:
logging.warning(f"Delete request for non-existent/invalid folder ID {folder_id} by user {tg_user_id}")
return jsonify({'status': 'error', 'message': 'Folder not found.'}), 404
folder_name = folder_node.get('name', 'folder')
# Check if folder is empty (safer to check 'children' array directly)
if 'children' in folder_node and isinstance(folder_node['children'], list) and folder_node['children']:
return jsonify({'status': 'error', 'message': f'Folder "{folder_name}" is not empty. Please delete its contents first.'}), 400
# Attempt to remove the folder node
if remove_node(user_data['filesystem'], folder_id):
# Attempt to save the change
if save_data(db_data):
logging.info(f"Folder {folder_id} ('{folder_name}') deleted by user {tg_user_id}")
return jsonify({'status': 'ok', 'message': f'Folder "{folder_name}" deleted.'})
else:
logging.error(f"Delete folder save error for user {tg_user_id} after removing node {folder_id}.")
# Attempt rollback (risky)
# add_node(user_data['filesystem'], parent_node['id'], folder_node)
return jsonify({'status': 'error', 'message': 'Error saving database after deleting folder.'}), 500
else:
# This indicates an internal logic error if the node was found before
logging.error(f"Failed to remove empty folder node {folder_id} from DB for {tg_user_id} after it was found.")
return jsonify({'status': 'error', 'message': 'Could not remove folder from database structure.'}), 500
@app.route('/get_text_content/<file_id>')
def get_text_content_route(file_id):
# NO AUTHENTICATION - relies on file_id obscurity
db_data = load_data()
file_node = None
owner_user_id = None
for user_id_scan, user_data_scan in db_data.get('users', {}).items():
if 'filesystem' in user_data_scan and isinstance(user_data_scan['filesystem'], dict):
node, _ = find_node_by_id(user_data_scan['filesystem'], file_id)
# Allow preview only for 'text' type files
if node and isinstance(node, dict) and node.get('type') == 'file' and node.get('file_type') == 'text':
file_node = node
owner_user_id = user_id_scan
break
if not file_node:
logging.warning(f"Text content request for unknown/non-text file_id: {file_id}")
return Response("Text file not found or preview not allowed", status=404, mimetype='text/plain')
hf_path = file_node.get('path')
if not hf_path:
logging.error(f"Text content error: Missing HF path for file ID {file_id} (Owner: {owner_user_id})")
return Response("Error: File path configuration missing", status=500, mimetype='text/plain')
file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}?download=true"
logging.info(f"Attempting to fetch text content from: {file_url}")
try:
headers = {}
if HF_TOKEN_READ:
headers["authorization"] = f"Bearer {HF_TOKEN_READ}"
response = requests.get(file_url, headers=headers, timeout=15) # Shorter timeout for text files
response.raise_for_status()
# Limit preview size to prevent loading huge files in browser
max_preview_size = 1 * 1024 * 1024 # 1 MB limit
if 'Content-Length' in response.headers and int(response.headers['Content-Length']) > max_preview_size:
logging.warning(f"Text file {file_id} too large for preview ({response.headers['Content-Length']} bytes).")
return Response("File is too large for preview (>1MB). Please download.", status=413, mimetype='text/plain') # 413 Payload Too Large
# If size is unknown or within limits, proceed to read content
content_bytes = response.content
if len(content_bytes) > max_preview_size:
logging.warning(f"Text file {file_id} too large for preview after download ({len(content_bytes)} bytes).")
return Response("File is too large for preview (>1MB). Please download.", status=413, mimetype='text/plain')
# Attempt to decode the text content
text_content = None
detected_encoding = None
# Try common encodings
encodings_to_try = ['utf-8', 'cp1251', 'latin-1']
for enc in encodings_to_try:
try:
text_content = content_bytes.decode(enc)
detected_encoding = enc
logging.info(f"Decoded text file {file_id} using {enc}")
break
except UnicodeDecodeError:
continue
if text_content is None:
# Fallback: Try to detect using chardet if installed, or assume UTF-8 lossy
try:
import chardet
result = chardet.detect(content_bytes)
detected_encoding = result['encoding']
if detected_encoding:
text_content = content_bytes.decode(detected_encoding, errors='replace')
logging.info(f"Decoded text file {file_id} using detected encoding {detected_encoding}")
else:
raise ValueError("Chardet could not detect encoding")
except (ImportError, Exception) as E:
logging.warning(f"Could not decode text file {file_id} with common encodings or chardet ({E}). Falling back to utf-8 replace.")
text_content = content_bytes.decode('utf-8', errors='replace')
detected_encoding = 'utf-8 (replaced errors)'
# Return decoded text with appropriate content type
return Response(text_content, mimetype=f'text/plain; charset={detected_encoding.split(" ")[0]}') # Use detected/fallback encoding
except requests.exceptions.Timeout:
logging.error(f"Timeout fetching text content from HF: {hf_path}")
return Response("Error: Timed out connecting to file storage", status=504, mimetype='text/plain')
except requests.exceptions.RequestException as e:
status_code = e.response.status_code if e.response is not None else 502
logging.error(f"Error fetching text content from HF ({hf_path}, Owner: {owner_user_id}): {e} (Status: {status_code})")
return Response(f"Error retrieving text content ({status_code})", status=status_code, mimetype='text/plain')
except Exception as e:
logging.error(f"Unexpected error fetching text content ({hf_path}, Owner: {owner_user_id}): {e}", exc_info=True)
return Response("Internal server error fetching text content", status=500, mimetype='text/plain')
@app.route('/preview_thumb/<file_id>')
def preview_thumb_route(file_id):
# NO AUTHENTICATION
db_data = load_data()
file_node = None
owner_user_id = None
for user_id_scan, user_data_scan in db_data.get('users', {}).items():
if 'filesystem' in user_data_scan and isinstance(user_data_scan['filesystem'], dict):
node, _ = find_node_by_id(user_data_scan['filesystem'], file_id)
if node and isinstance(node, dict) and node.get('type') == 'file' and node.get('file_type') == 'image':
file_node = node
owner_user_id = user_id_scan
break
if not file_node: return Response("Image not found", status=404, mimetype='text/plain')
hf_path = file_node.get('path')
if not hf_path: return Response("Error: File path missing", status=500, mimetype='text/plain')
# Use the /resolve/main path for direct file access
file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}"
logging.info(f"Attempting to serve image preview via proxy from: {file_url}")
try:
headers = {}
if HF_TOKEN_READ: headers["authorization"] = f"Bearer {HF_TOKEN_READ}"
response = requests.get(file_url, headers=headers, stream=True, timeout=20)
response.raise_for_status()
# Stream the image content directly
resp_headers = {}
content_type = response.headers.get('Content-Type', 'application/octet-stream')
# Basic validation it looks like an image type
if not content_type.startswith('image/'):
logging.warning(f"HF returned non-image content type '{content_type}' for image preview request: {hf_path}")
# Fallback or return error? Let's try returning it anyway.
# return Response("Invalid content type from storage", status=502, mimetype='text/plain')
resp_headers['Content-Type'] = content_type
if 'Content-Length' in response.headers:
resp_headers['Content-Length'] = response.headers['Content-Length']
# Add cache headers? Maybe Cache-Control: public, max-age=3600 ?
return Response(response.iter_content(chunk_size=8192), status=response.status_code, headers=resp_headers)
except requests.exceptions.Timeout:
logging.error(f"Timeout fetching preview from HF: {hf_path}")
return Response("Error: Timed out connecting to storage", status=504, mimetype='text/plain')
except requests.exceptions.RequestException as e:
status_code = e.response.status_code if e.response is not None else 502
logging.error(f"Error fetching preview from HF ({hf_path}, Owner: {owner_user_id}): {e} (Status: {status_code})")
return Response(f"Error retrieving preview ({status_code})", status=status_code, mimetype='text/plain')
except Exception as e:
logging.error(f"Unexpected error during preview proxy ({hf_path}, Owner: {owner_user_id}): {e}", exc_info=True)
return Response("Internal server error during preview", status=500, mimetype='text/plain')
# --- Main Execution ---
if __name__ == '__main__':
print("Starting Zeus Cloud Mini App Backend...")
logging.info("Starting Zeus Cloud Mini App Backend...")
# Initial sanity checks
if not BOT_TOKEN or BOT_TOKEN == 'YOUR_BOT_TOKEN':
logging.critical("\n" + "*"*60 +
"\n CRITICAL: TELEGRAM_BOT_TOKEN is not set correctly. " +
"\n Telegram authentication WILL FAIL. Set the environment variable." +
"\n" + "*"*60)
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (write access) is not set. File uploads & deletions will fail.")
if not HF_TOKEN_READ and HF_TOKEN_WRITE:
logging.info("HF_TOKEN_READ not set, using HF_TOKEN (write token) for read access.")
elif not HF_TOKEN_READ and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_READ is not set. File downloads/previews might fail if repo is private.")
if not REPO_ID:
logging.critical("HF REPO_ID is not set. Application cannot function.")
exit(1)
logging.info(f"Using HF Repo: {REPO_ID}")
logging.info(f"Data file: {DATA_FILE}")
# Attempt initial data load/sync
logging.info("Performing initial database sync/load...")
initial_data = load_data()
if not initial_data or not initial_data.get('users'):
logging.warning("Initial data load resulted in empty or invalid data. Check logs.")
else:
logging.info(f"Initial data loaded. User count: {len(initial_data['users'])}")
# Run Flask app
# Use waitress or gunicorn in production instead of Flask's development server
logging.info("Starting Flask server...")
try:
# For production deployment, replace app.run with a production server like waitress or gunicorn
# Example using waitress (install with: pip install waitress):
# from waitress import serve
# serve(app, host='0.0.0.0', port=7860)
# Using Flask's development server (set debug=False for production-like behavior)
app.run(debug=False, host='0.0.0.0', port=7860)
except Exception as run_e:
logging.critical(f"Failed to start Flask server: {run_e}", exc_info=True)
exit(1)
# --- END OF FILE app (24).py ---