|
|
|
|
|
|
|
|
|
|
|
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', {}) |
|
|
|
|
|
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()): |
|
|
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...") |
|
|
|
|
|
download_success = download_db_from_hf() |
|
|
|
|
|
|
|
|
data = load_data_from_file(DATA_FILE) |
|
|
if data is not None: |
|
|
logging.info("Using main data file.") |
|
|
return data |
|
|
|
|
|
|
|
|
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.") |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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.") |
|
|
|
|
|
return False |
|
|
|
|
|
try: |
|
|
|
|
|
with open(DATA_FILE_TEMP, 'w', encoding='utf-8') as file: |
|
|
json.dump(data, file, ensure_ascii=False, indent=4) |
|
|
|
|
|
|
|
|
if os.path.exists(DATA_FILE): |
|
|
try: |
|
|
shutil.copy(DATA_FILE, DATA_FILE_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) |
|
|
|
|
|
cache.clear() |
|
|
logging.info("Data saved successfully to " + DATA_FILE) |
|
|
|
|
|
|
|
|
upload_thread = threading.Thread(target=upload_db_to_hf) |
|
|
upload_thread.start() |
|
|
return True |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error saving data: {e}") |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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')}", |
|
|
|
|
|
) |
|
|
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, |
|
|
etag_timeout=10, |
|
|
resume_download=False, |
|
|
cache_dir=None, |
|
|
local_path=local_path_tmp |
|
|
) |
|
|
|
|
|
|
|
|
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() |
|
|
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}") |
|
|
|
|
|
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 |
|
|
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})") |
|
|
|
|
|
pass |
|
|
|
|
|
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">×</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"> </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, "<").replace(/>/g, ">"); // 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() |
|
|
} |
|
|
initialize_user_filesystem(users[tg_user_id]) |
|
|
save_needed = True |
|
|
else: |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
if user_entry.get('user_info', {}).get('username') != user_info.get('username'): |
|
|
user_entry['user_info'] = user_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.") |
|
|
|
|
|
|
|
|
pass |
|
|
|
|
|
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}") |
|
|
|
|
|
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}") |
|
|
|
|
|
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.") |
|
|
|
|
|
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: |
|
|
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'] = [] |
|
|
|
|
|
|
|
|
|
|
|
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 = [] |
|
|
|
|
|
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] |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
hf_path = f"cloud_files/{tg_user_id}/{file_id[:2]}/{file_id}_{unique_filename}" |
|
|
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, |
|
|
'path': hf_path, |
|
|
'file_type': get_file_type(original_filename), |
|
|
'upload_date': datetime.now().isoformat() |
|
|
} |
|
|
|
|
|
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})") |
|
|
|
|
|
|
|
|
if add_node(user_data['filesystem'], current_folder_id, file_info): |
|
|
uploaded_count += 1 |
|
|
nodes_added.append(file_info) |
|
|
else: |
|
|
|
|
|
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}") |
|
|
|
|
|
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}") |
|
|
|
|
|
if file_info in nodes_added: nodes_added.remove(file_info) |
|
|
finally: |
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
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): |
|
|
|
|
|
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.") |
|
|
|
|
|
|
|
|
for node_info in nodes_added: |
|
|
remove_node(user_data['filesystem'], node_info['id']) |
|
|
uploaded_count = 0 |
|
|
|
|
|
|
|
|
final_message = f"{uploaded_count} file(s) uploaded." |
|
|
if errors: |
|
|
final_message += f" Errors occurred with {len(errors)} file(s)." |
|
|
|
|
|
|
|
|
|
|
|
return jsonify({ |
|
|
"status": "ok" if uploaded_count > 0 else "error", |
|
|
"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 |
|
|
|
|
|
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}" |
|
|
|
|
|
|
|
|
if 'Content-Length' in response.headers: |
|
|
resp_headers['Content-Length'] = response.headers['Content-Length'] |
|
|
|
|
|
|
|
|
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') |
|
|
except requests.exceptions.RequestException as e: |
|
|
status_code = e.response.status_code if e.response is not None else 502 |
|
|
logging.error(f"Error downloading file from HF ({hf_path}, Owner: {owner_user_id}): {e} (Status: {status_code})") |
|
|
|
|
|
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: |
|
|
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}") |
|
|
|
|
|
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: |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
except Exception as e: |
|
|
logging.error(f"Error deleting file from HF Hub ({hf_path}, User: {tg_user_id}): {e}") |
|
|
|
|
|
|
|
|
|
|
|
else: |
|
|
logging.warning(f"File node {file_id} for user {tg_user_id} has no HF path. Skipping HF deletion.") |
|
|
hf_deleted = True |
|
|
|
|
|
|
|
|
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}") |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
else: |
|
|
|
|
|
logging.error(f"Failed to remove file node {file_id} from DB structure for {tg_user_id} after it was found.") |
|
|
|
|
|
|
|
|
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: |
|
|
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') |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
if remove_node(user_data['filesystem'], folder_id): |
|
|
|
|
|
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}.") |
|
|
|
|
|
|
|
|
return jsonify({'status': 'error', 'message': 'Error saving database after deleting folder.'}), 500 |
|
|
else: |
|
|
|
|
|
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): |
|
|
|
|
|
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') == '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) |
|
|
response.raise_for_status() |
|
|
|
|
|
|
|
|
max_preview_size = 1 * 1024 * 1024 |
|
|
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') |
|
|
|
|
|
|
|
|
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') |
|
|
|
|
|
|
|
|
text_content = None |
|
|
detected_encoding = None |
|
|
|
|
|
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: |
|
|
|
|
|
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 Response(text_content, mimetype=f'text/plain; charset={detected_encoding.split(" ")[0]}') |
|
|
|
|
|
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): |
|
|
|
|
|
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') |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
resp_headers = {} |
|
|
content_type = response.headers.get('Content-Type', 'application/octet-stream') |
|
|
|
|
|
if not content_type.startswith('image/'): |
|
|
logging.warning(f"HF returned non-image content type '{content_type}' for image preview request: {hf_path}") |
|
|
|
|
|
|
|
|
|
|
|
resp_headers['Content-Type'] = content_type |
|
|
if 'Content-Length' in response.headers: |
|
|
resp_headers['Content-Length'] = response.headers['Content-Length'] |
|
|
|
|
|
|
|
|
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') |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
|
print("Starting Zeus Cloud Mini App Backend...") |
|
|
logging.info("Starting Zeus Cloud Mini App Backend...") |
|
|
|
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
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'])}") |
|
|
|
|
|
|
|
|
|
|
|
logging.info("Starting Flask server...") |
|
|
try: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|