diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,4 +1,6 @@ -from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify +# --- START OF FILE app.py --- + +from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, make_response from flask_caching import Cache import json import os @@ -6,382 +8,685 @@ import logging import threading import time from datetime import datetime -from huggingface_hub import HfApi, hf_hub_download +from huggingface_hub import HfApi, hf_hub_download, hf_hub_url from werkzeug.utils import secure_filename import requests from io import BytesIO import uuid +from functools import wraps import mimetypes -import PyPDF2 -from base64 import b64encode app = Flask(__name__) -app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey") -DATA_FILE = 'cloudeng_data.json' -REPO_ID = "Eluza133/Z1e1u" +app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_dev_12345") # Use a strong key in production +DATA_FILE = 'cloudeng_data_v2.json' # Changed filename for new structure +REPO_ID = "Eluza133/Z1e1u" # Replace with your actual repo ID HF_TOKEN_WRITE = os.getenv("HF_TOKEN") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE +# Basic Input Validation +ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'mp4', 'mov', 'avi', 'mkv', 'webm', 'md', 'log', 'csv', 'json', 'xml', 'html', 'css', 'js', 'py', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar', '7z'} +MAX_FILENAME_LENGTH = 200 +MAX_FOLDER_NAME_LENGTH = 100 +INVALID_CHARS = '/\\:*?"<>|' + cache = Cache(app, config={'CACHE_TYPE': 'simple'}) -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +# --- Helper Functions --- + +def is_safe_name(name, max_length): + if not name or len(name) > max_length: + return False + if any(c in name for c in INVALID_CHARS): + return False + return True + +def get_extension(filename): + return os.path.splitext(filename)[1].lower().lstrip('.') + +def allowed_file(filename): + # Allow any extension for now, but keep the function for potential future use + # extension = get_extension(filename) + # return '.' in filename and extension in ALLOWED_EXTENSIONS + return '.' in filename and len(filename) <= MAX_FILENAME_LENGTH + +def get_file_type(filename): + extension = get_extension(filename) + if extension in ['mp4', 'mov', 'avi', 'mkv', 'webm']: + return 'video' + elif extension in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']: + return 'image' + elif extension == 'pdf': + return 'pdf' + elif extension in ['txt', 'md', 'log', 'csv', 'json', 'xml', 'html', 'css', 'js', 'py']: + return 'text' + else: + return 'other' + +def generate_unique_filename(original_filename): + name, ext = os.path.splitext(original_filename) + unique_id = uuid.uuid4().hex[:8] # Shorter UUID part + # Truncate name if necessary to stay within limits after adding UUID + max_name_len = MAX_FILENAME_LENGTH - len(ext) - len(unique_id) - 2 # -2 for "_" and potential "." issues + safe_name = "".join(c for c in name if c not in INVALID_CHARS).strip() + truncated_name = safe_name[:max_name_len] + return f"{truncated_name}_{unique_id}{ext}" + +def build_hf_path(username, current_path_list, unique_filename): + base = f"cloud_files/{username}" + if current_path_list: + folder_path = "/".join(current_path_list) + return f"{base}/{folder_path}/{unique_filename}" + else: + return f"{base}/{unique_filename}" -@cache.memoize(timeout=300) +def parse_path(path_str): + if not path_str: + return [] + # Sanitize: remove leading/trailing slashes, replace multiple slashes + cleaned_path = '/'.join(filter(None, path_str.strip('/').split('/'))) + # Further validation against invalid characters in each part + parts = cleaned_path.split('/') + if not all(is_safe_name(part, MAX_FOLDER_NAME_LENGTH) for part in parts): + logging.warning(f"Invalid path component detected in: {path_str}") + return None # Indicate invalid path + return parts + +def navigate_to_path(user_root, path_list): + current_level = user_root + for folder_name in path_list: + if folder_name not in current_level.get('folders', {}): + return None # Path does not exist + current_level = current_level['folders'][folder_name] + return current_level + +def get_breadcrumbs(path_str): + breadcrumbs = [{'name': 'Home', 'path': ''}] + parts = parse_path(path_str) + if parts is None: # Invalid path string + return breadcrumbs # Return only Home + current_path = [] + for part in parts: + current_path.append(part) + breadcrumbs.append({'name': part, 'path': '/'.join(current_path)}) + return breadcrumbs + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'username' not in session: + flash('Please log in to access this page.', 'warning') + return redirect(url_for('login')) + # Ensure user still exists in data + data = load_data() + if session['username'] not in data.get('users', {}): + session.pop('username', None) + flash('Your user account could not be found. Please log in again.', 'error') + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated_function + +# --- Data Handling --- + +def initialize_user_root(): + return {'files': [], 'folders': {}} + +@cache.memoize(timeout=120) # Cache for 2 minutes def load_data(): try: download_db_from_hf() with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) - if not isinstance(data, dict): - logging.warning("Data is not in dict format, initializing empty database") - return {'users': {}, 'files': {}, 'folders': {}} - data.setdefault('users', {}) - data.setdefault('files', {}) - data.setdefault('folders', {}) - logging.info("Data successfully loaded") - return data + if not isinstance(data, dict): + logging.warning("Data is not dict, initializing.") + data = {'users': {}} + data.setdefault('users', {}) + # Ensure all users have the new 'root' structure + for username, user_data in data['users'].items(): + if 'root' not in user_data: + logging.info(f"Initializing root structure for user: {username}") + user_data['root'] = initialize_user_root() + # Attempt to migrate old 'files' if they exist + if 'files' in user_data and isinstance(user_data['files'], list): + logging.info(f"Migrating old file list for user: {username}") + user_data['root']['files'] = user_data.pop('files') + # Ensure root always has files and folders keys + user_data['root'].setdefault('files', []) + user_data['root'].setdefault('folders', {}) + + logging.info(f"Data loaded successfully. Users: {len(data['users'])}") + return data + except FileNotFoundError: + logging.warning(f"{DATA_FILE} not found. Initializing empty database.") + return {'users': {}} + except json.JSONDecodeError: + logging.error(f"Error decoding {DATA_FILE}. Initializing empty database.") + # Optionally: backup the corrupted file + # os.rename(DATA_FILE, f"{DATA_FILE}.corrupted_{int(time.time())}") + return {'users': {}} except Exception as e: - logging.error(f"Error loading data: {e}") - return {'users': {}, 'files': {}, 'folders': {}} + logging.error(f"Unexpected error loading data: {e}", exc_info=True) + return {'users': {}} # Safer fallback def save_data(data): try: + # Create a backup before overwriting + if os.path.exists(DATA_FILE): + os.rename(DATA_FILE, f"{DATA_FILE}.bak") + with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) + + # Upload after successful local save upload_db_to_hf() cache.clear() - logging.info("Data saved and uploaded to HF") + logging.info("Data saved locally and upload initiated.") + + # Remove backup after successful save and upload attempt + if os.path.exists(f"{DATA_FILE}.bak"): + os.remove(f"{DATA_FILE}.bak") + except Exception as e: - logging.error(f"Error saving data: {e}") - raise + logging.error(f"Error saving data: {e}", exc_info=True) + # Optionally restore from backup if save failed mid-way + if os.path.exists(f"{DATA_FILE}.bak"): + try: + os.rename(f"{DATA_FILE}.bak", DATA_FILE) + logging.info("Restored data from backup due to save error.") + except Exception as restore_e: + logging.error(f"Failed to restore data from backup: {restore_e}") + raise # Re-raise the original exception + +# --- Hugging Face Interaction --- + +def _get_hf_api(): + if not HF_TOKEN_WRITE: + logging.warning("Write operations require HF_TOKEN env variable.") + return None + return HfApi() + +def _get_hf_token_read(): + return HF_TOKEN_READ def upload_db_to_hf(): + if not HF_TOKEN_WRITE: + logging.warning("Skipping DB upload: HF_TOKEN (write) not set.") + return + if not os.path.exists(DATA_FILE): + logging.warning(f"Skipping DB upload: {DATA_FILE} not found.") + 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 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - ) - logging.info("Database uploaded to Hugging Face") + api = _get_hf_api() + if api: + api.upload_file( + path_or_fileobj=DATA_FILE, + path_in_repo=DATA_FILE, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, # Explicitly use write token + commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) + logging.info(f"Database uploaded to HF repo: {REPO_ID}") + else: + logging.error("Failed to get HfApi instance for upload.") except Exception as e: - logging.error(f"Error uploading database: {e}") + logging.error(f"Error uploading database to HF: {e}", exc_info=True) def download_db_from_hf(): + token = _get_hf_token_read() + if not token: + logging.warning("Skipping DB download: No read token (HF_TOKEN_READ or HF_TOKEN) found.") + # If the file doesn't exist locally, create an empty one + if not os.path.exists(DATA_FILE): + logging.warning(f"{DATA_FILE} not found locally and cannot download. Creating empty DB.") + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}}, f) + return + try: hf_hub_download( repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", - token=HF_TOKEN_READ, + token=token, local_dir=".", - local_dir_use_symlinks=False + local_dir_use_symlinks=False, + force_download=True, # Ensure we get the latest version + etag_timeout=10 # Short timeout for checking changes ) - logging.info("Database downloaded from Hugging Face") + logging.info(f"Database downloaded/verified from HF repo: {REPO_ID}") except Exception as e: - logging.error(f"Error downloading database: {e}") + # More specific error handling could be added (e.g., 404 Not Found vs. connection error) + logging.error(f"Error downloading database from HF: {e}. Using local version if available.") + # If download fails, check if a local file exists. If not, create an empty one. if not os.path.exists(DATA_FILE): + logging.warning(f"{DATA_FILE} not found locally and download failed. Creating empty DB.") with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}, 'files': {}, 'folders': {}}, f) + json.dump({'users': {}}, f) + +# --- Periodic Backup --- def periodic_backup(): + logging.info("Periodic backup thread started.") while True: - upload_db_to_hf() - time.sleep(1800) - -def get_file_type(filename): - mime_type, _ = mimetypes.guess_type(filename) - if mime_type: - if mime_type.startswith('video'): - return 'video' - elif mime_type.startswith('image'): - return 'image' - elif mime_type == 'application/pdf': - return 'pdf' - elif mime_type == 'text/plain': - return 'txt' - return 'other' - -def generate_unique_filename(filename): - unique_id = str(uuid.uuid4()) - _, ext = os.path.splitext(filename) - return f"{unique_id}{ext}" + time.sleep(1800) # 30 minutes + logging.info("Initiating periodic backup...") + try: + # Ensure data is loaded from disk before backup in case of in-memory changes not saved + # (Though save_data should handle this, it's a safety measure) + # load_data() # Re-load might clear cache unnecessarily, rely on save_data discipline + if os.path.exists(DATA_FILE): + upload_db_to_hf() + else: + logging.warning("Periodic backup skipped: data file not found.") + except Exception as e: + logging.error(f"Error during periodic backup: {e}", exc_info=True) +# --- Base Style --- +# (Reduced height for brevity, assumes it's defined elsewhere or kept minimal) BASE_STYLE = ''' :root { - --primary: #ff4d6d; - --secondary: #00ddeb; - --accent: #8b5cf6; - --background-light: #f5f6fa; - --background-dark: #1a1625; - --card-bg: rgba(255, 255, 255, 0.95); - --card-bg-dark: rgba(40, 35, 60, 0.95); - --text-light: #2a1e5a; - --text-dark: #e8e1ff; - --shadow: 0 10px 30px rgba(0, 0, 0, 0.2); - --glass-bg: rgba(255, 255, 255, 0.15); + --primary-color: #6a11cb; /* Deep Purple */ + --secondary-color: #2575fc; /* Bright Blue */ + --accent-color: #ff5f6d; /* Coral Pink */ + --bg-light: #f8f9fa; + --bg-dark: #1a1a2e; /* Dark Navy */ + --text-light: #343a40; + --text-dark: #e0e0fc; /* Light Lavender */ + --card-bg-light: #ffffff; + --card-bg-dark: #2a2a4a; /* Slightly Lighter Navy */ + --shadow-light: 0 4px 15px rgba(0, 0, 0, 0.1); + --shadow-dark: 0 4px 15px rgba(0, 0, 0, 0.3); + --border-radius: 12px; --transition: all 0.3s ease; - --delete-color: #ff4444; + --font-family: 'Inter', sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: 'Inter', sans-serif; - background: var(--background-light); + font-family: var(--font-family); + background: var(--bg-light); color: var(--text-light); line-height: 1.6; + transition: var(--transition); } body.dark { - background: var(--background-dark); + background: var(--bg-dark); color: var(--text-dark); } .container { - margin: 20px auto; - max-width: 1200px; - padding: 25px; - background: var(--card-bg); - border-radius: 20px; - box-shadow: var(--shadow); + max-width: 1400px; /* Wider container */ + margin: 30px auto; + padding: 30px; + background: var(--card-bg-light); + border-radius: var(--border-radius); + box-shadow: var(--shadow-light); + transition: var(--transition); } body.dark .container { background: var(--card-bg-dark); + box-shadow: var(--shadow-dark); } -h1 { - font-size: 2em; - font-weight: 800; - text-align: center; - margin-bottom: 25px; - background: linear-gradient(135deg, var(--primary), var(--accent)); - -webkit-background-clip: text; - color: transparent; -} -h2 { - font-size: 1.5em; - margin-top: 30px; - color: var(--text-light); +h1, h2 { + font-weight: 700; + margin-bottom: 20px; + color: var(--primary-color); } -body.dark h2 { - color: var(--text-dark); +body.dark h1, body.dark h2 { + color: var(--secondary-color); } -input, textarea { +h1 { font-size: 2.2em; text-align: center; } +h2 { font-size: 1.6em; margin-top: 30px; border-bottom: 1px solid #eee; padding-bottom: 10px; } +body.dark h2 { border-bottom-color: #444; } + +input[type="text"], input[type="password"], input[type="file"], textarea { width: 100%; - padding: 14px; - margin: 12px 0; - border: none; - border-radius: 14px; - background: var(--glass-bg); - color: var(--text-light); - font-size: 1.1em; - box-shadow: inset 0 3px 10px rgba(0, 0, 0, 0.1); + padding: 12px 15px; + margin-bottom: 15px; + border: 1px solid #ccc; + border-radius: 8px; + background-color: #fff; + font-size: 1em; + transition: var(--transition); } -body.dark input, body.dark textarea { +body.dark input, body.dark textarea, body.dark input[type="file"]::-webkit-file-upload-button { + background-color: var(--card-bg-dark); + border-color: #555; color: var(--text-dark); } input:focus, textarea:focus { outline: none; - box-shadow: 0 0 0 4px var(--primary); + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(106, 17, 203, 0.2); +} +body.dark input:focus, body.dark textarea:focus { + border-color: var(--secondary-color); + box-shadow: 0 0 0 3px rgba(37, 117, 252, 0.3); } + .btn { - padding: 14px 28px; - background: var(--primary); - color: white; + padding: 12px 25px; border: none; - border-radius: 14px; + border-radius: 8px; cursor: pointer; - font-size: 1.1em; + font-size: 1em; font-weight: 600; transition: var(--transition); - box-shadow: var(--shadow); - display: inline-block; text-decoration: none; + display: inline-block; + text-align: center; + background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); + color: white; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); } .btn:hover { - transform: scale(1.05); - background: #e6415f; -} -.download-btn { - background: var(--secondary); - margin-top: 10px; + opacity: 0.9; + box-shadow: 0 4px 10px rgba(0,0,0,0.15); + transform: translateY(-2px); } -.download-btn:hover { - background: #00b8c5; +.btn-secondary { + background: #6c757d; /* Bootstrap secondary */ + color: white; } -.delete-btn { - background: var(--delete-color); - margin-top: 10px; +.btn-danger { + background: var(--accent-color); + color: white; } -.delete-btn:hover { - background: #cc3333; +.btn-small { + padding: 6px 12px; + font-size: 0.9em; } + .flash { - color: var(--secondary); + padding: 15px; + margin-bottom: 20px; + border-radius: var(--border-radius); text-align: center; - margin-bottom: 15px; + font-weight: 500; } +.flash.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } +.flash.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } +.flash.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } +body.dark .flash.success { background-color: #1a3a24; color: #c3e6cb; border-color: #3a5f46; } +body.dark .flash.error { background-color: #4d1f24; color: #f5c6cb; border-color: #7d3a41; } +body.dark .flash.warning { background-color: #5a480f; color: #ffeeba; border-color: #8e793e; } + .file-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 20px; - margin-top: 20px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 25px; + margin-top: 25px; } -.folder-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 20px; - margin-top: 20px; -} -.user-list { - margin-top: 20px; -} -.user-item { +.item-card { + background: var(--card-bg-light); + border-radius: var(--border-radius); + box-shadow: var(--shadow-light); padding: 15px; - background: var(--card-bg); - border-radius: 16px; - margin-bottom: 10px; - box-shadow: var(--shadow); + text-align: center; transition: var(--transition); + word-wrap: break-word; + position: relative; /* For action buttons */ } -body.dark .user-item { +body.dark .item-card { background: var(--card-bg-dark); + box-shadow: var(--shadow-dark); } -.user-item:hover { +.item-card:hover { transform: translateY(-5px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); } -.user-item a { - color: var(--primary); - text-decoration: none; - font-weight: 600; +body.dark .item-card:hover { + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); } -.user-item a:hover { - color: var(--accent); +.item-icon { + font-size: 3.5em; /* Larger icons */ + margin-bottom: 15px; + display: block; + color: var(--secondary-color); + cursor: pointer; /* Make icon clickable */ } -@media (max-width: 768px) { - .file-grid, .folder-grid { - grid-template-columns: repeat(2, 1fr); - } +.item-icon.folder { color: #ffc107; } /* Yellow for folders */ +.item-preview { + width: 100%; + height: 120px; /* Fixed height */ + object-fit: cover; /* Cover the area */ + border-radius: 8px; + margin-bottom: 10px; + background-color: #eee; /* Placeholder bg */ + cursor: pointer; } -@media (max-width: 480px) { - .file-grid, .folder-grid { - grid-template-columns: 1fr; - } +body.dark .item-preview { background-color: #333; } + +.item-name { + font-weight: 600; + font-size: 0.95em; + margin-bottom: 5px; + display: block; /* Ensure it takes full width */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.file-item, .folder-item { - background: var(--card-bg); - padding: 15px; - border-radius: 16px; - box-shadow: var(--shadow); - text-align: center; - transition: var(--transition); +.item-meta { + font-size: 0.8em; + color: #6c757d; + margin-bottom: 10px; } -body.dark .file-item, body.dark .folder-item { - background: var(--card-bg-dark); +body.dark .item-meta { color: #aaa; } +.item-actions { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; /* Wrap if needed */ } -.file-item:hover, .folder-item:hover { - transform: translateY(-5px); +.item-actions .btn { + padding: 6px 10px; /* Smaller buttons */ + font-size: 0.85em; } -.file-preview { - max-width: 100%; - max-height: 200px; - object-fit: cover; - border-radius: 10px; - margin-bottom: 10px; - loading: lazy; + +/* Breadcrumbs */ +.breadcrumbs { + margin-bottom: 25px; + padding: 10px 15px; + background-color: #e9ecef; + border-radius: 8px; + font-size: 0.95em; } -.file-item p, .folder-item p { - font-size: 0.9em; - margin: 5px 0; +body.dark .breadcrumbs { + background-color: var(--card-bg-dark); } -.file-item a, .folder-item a { - color: var(--primary); +.breadcrumbs a { + color: var(--primary-color); text-decoration: none; + font-weight: 500; } -.file-item a:hover, .folder-item a:hover { - color: var(--accent); +body.dark .breadcrumbs a { color: var(--secondary-color); } +.breadcrumbs span { + margin: 0 8px; + color: #6c757d; } +body.dark .breadcrumbs span { color: #aaa; } +.breadcrumbs .current-page { + font-weight: 600; + color: var(--text-light); +} +body.dark .breadcrumbs .current-page { color: var(--text-dark); } + +/* Modal */ .modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - z-index: 2000; - justify-content: center; - align-items: center; + display: none; position: fixed; z-index: 1050; /* High z-index */ + left: 0; top: 0; width: 100%; height: 100%; + overflow: auto; background-color: rgba(0,0,0,0.85); + justify-content: center; align-items: center; padding: 20px; +} +.modal-content { + position: relative; background-color: #fff; margin: auto; padding: 0; + border: 1px solid #888; width: 85%; max-width: 1000px; + box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); + animation-name: animatetop; animation-duration: 0.4s; + border-radius: var(--border-radius); display: flex; flex-direction: column; + max-height: 90vh; /* Limit height */ } -.modal img, .modal video, .modal iframe, .modal pre { - max-width: 95%; - max-height: 95%; - object-fit: contain; - border-radius: 20px; - box-shadow: var(--shadow); +body.dark .modal-content { background-color: var(--card-bg-dark); border-color: #555;} +@keyframes animatetop { from {top:-300px; opacity:0} to {top:0; opacity:1} } +.modal-header { + padding: 10px 16px; background-color: var(--primary-color); color: white; + border-top-left-radius: var(--border-radius); border-top-right-radius: var(--border-radius); + display: flex; justify-content: space-between; align-items: center; } +body.dark .modal-header { background-color: var(--secondary-color); } +.modal-title { font-size: 1.25em; font-weight: 600; } +.modal-close { + color: white; font-size: 28px; font-weight: bold; + background: none; border: none; cursor: pointer; line-height: 1; +} +.modal-body { padding: 20px; overflow-y: auto; /* Scrollable body */ flex-grow: 1; } +.modal-body img, .modal-body video, .modal-body iframe { + max-width: 100%; display: block; margin: 0 auto; border-radius: 8px; + max-height: 70vh; /* Limit media height within modal */ +} +.modal-body pre { /* For text files */ + white-space: pre-wrap; word-wrap: break-word; background: #f8f8f8; + padding: 15px; border-radius: 8px; max-height: 65vh; overflow: auto; + font-family: monospace; +} +body.dark .modal-body pre { background: #222; color: #eee; } +#modal-loader { display: none; text-align: center; padding: 30px; font-size: 1.2em; } + #progress-container { - width: 100%; - background: var(--glass-bg); - border-radius: 10px; - margin: 15px 0; - display: none; + width: 100%; background-color: #e9ecef; border-radius: 8px; margin: 15px 0; + height: 25px; overflow: hidden; display: none; } +body.dark #progress-container { background-color: #444; } #progress-bar { - width: 0%; - height: 20px; - background: var(--primary); - border-radius: 10px; - transition: width 0.3s ease; + width: 0%; height: 100%; + background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); + border-radius: 8px; transition: width 0.3s ease; + display: flex; align-items: center; justify-content: center; + color: white; font-weight: 600; font-size: 0.9em; } + +/* Form Groups */ +.form-group { margin-bottom: 20px; } +.form-group label { display: block; margin-bottom: 5px; font-weight: 600; } + +/* Action Bar */ +.action-bar { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 25px; + padding-bottom: 20px; + border-bottom: 1px solid #eee; + align-items: center; +} +body.dark .action-bar { border-bottom-color: #444; } +.action-bar .form-group { margin-bottom: 0; flex-grow: 1; } +/* Hide default file input text */ +input[type="file"] { color: transparent; } +input[type="file"]::file-selector-button { /* Style the button */ + padding: 10px 15px; border: none; border-radius: 6px; + background: var(--secondary-color); color: white; cursor: pointer; + font-weight: 500; transition: var(--transition); margin-right: 10px; +} +input[type="file"]::file-selector-button:hover { opacity: 0.9; } +body.dark input[type="file"]::file-selector-button { background: var(--primary-color); } +/* Minimalist Folder Input */ +.folder-create-wrapper { display: flex; gap: 10px; align-items: center; } +.folder-create-wrapper input { margin-bottom: 0; flex-grow: 1; } + +/* Admin User List */ +.user-list { margin-top: 20px; } +.user-item { + display: flex; justify-content: space-between; align-items: center; + padding: 15px; background: var(--card-bg-light); border-radius: var(--border-radius); + margin-bottom: 10px; box-shadow: var(--shadow-light); transition: var(--transition); +} +body.dark .user-item { background: var(--card-bg-dark); box-shadow: var(--shadow-dark); } +.user-item:hover { transform: translateX(5px); } +.user-info a { color: var(--primary-color); text-decoration: none; font-weight: 600; font-size: 1.1em; } +body.dark .user-info a { color: var(--secondary-color); } +.user-info p { font-size: 0.9em; color: #6c757d; margin-top: 3px; } +body.dark .user-info p { color: #aaa; } +.user-actions .btn { margin-left: 10px; } + +/* FontAwesome Icons (requires including FontAwesome) */ +@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css'); + ''' +# --- Routes --- + @app.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'POST': - username = request.form.get('username') + username = request.form.get('username', '').strip() password = request.form.get('password') + + if not is_safe_name(username, 50) or not username.isalnum(): # Basic username validation + flash('Имя пользователя должно быть буквенно-цифровым, без специальных символов и не длиннее 50 символов.', 'error') + return redirect(url_for('register')) + + if not password or len(password) < 6: + flash('Пароль должен быть не менее 6 символов.', 'error') + return redirect(url_for('register')) + data = load_data() + if username in data['users']: - flash('Пользователь с таким именем уже существует!') - return redirect(url_for('register')) - if not username or not password: - flash('Имя пользователя и пароль обязательн��!') + flash('Пользователь с таким именем уже существует!', 'error') return redirect(url_for('register')) + data['users'][username] = { - 'password': password, + 'password': password, # In a real app, HASH the password! 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'files': [], - 'folders': [] + 'root': initialize_user_root() } - save_data(data) - session['username'] = username - flash('Регистрация прошла успешно!') - return redirect(url_for('dashboard')) + try: + save_data(data) + session['username'] = username + flash('Регистрация прошла успешно!', 'success') + return redirect(url_for('dashboard')) + except Exception as e: + flash('Ошибка при сохранении данных. Попробуйте позже.', 'error') + logging.error(f"Failed to save data during registration for {username}: {e}") + # Attempt to remove the partially added user if save failed + if username in data['users']: + del data['users'][username] # Remove from in-memory dict before potential retry + return redirect(url_for('register')) + + html = ''' - Регистрация - Zeus Cloud - + Регистрация - Zeus Cloud V2 + -
-

Регистрация в Zeus Cloud

- {% with messages = get_flashed_messages() %} +
+

Регистрация

+ {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} - {% for message in messages %} -
{{ message }}
+ {% for category, message in messages %} +
{{ message }}
{% endfor %} {% endif %} {% endwith %}
- - - +
+ + +
+
+ + +
+
-

Уже есть аккаунт? Войти

+

Уже есть аккаунт? Войти

@@ -391,81 +696,120 @@ def register(): @app.route('/', methods=['GET', 'POST']) def login(): if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') + content_type = request.headers.get('Content-Type') + is_json = content_type and 'application/json' in content_type + + if is_json: + req_data = request.get_json() + username = req_data.get('username') + password = req_data.get('password') + else: # Assume form data + username = request.form.get('username') + password = request.form.get('password') + + if not username or not password: + if is_json: return jsonify({'status': 'error', 'message': 'Имя пользователя и пароль обязательны!'}), 400 + else: flash('Имя пользователя и пароль обязательны!', 'error'); return redirect(url_for('login')) + data = load_data() - if username in data['users'] and data['users'][username]['password'] == password: + + if username in data.get('users', {}) and data['users'][username].get('password') == password: session['username'] = username - return jsonify({'status': 'success', 'redirect': url_for('dashboard')}) + session.permanent = True # Make session last longer + app.permanent_session_lifetime = timedelta(days=30) # Example: 30 days + + if is_json: return jsonify({'status': 'success', 'redirect': url_for('dashboard')}) + else: flash('Вход выполнен успешно!', 'success'); return redirect(url_for('dashboard')) else: - return jsonify({'status': 'error', 'message': 'Неверное имя пользователя или пароль!'}) + if is_json: return jsonify({'status': 'error', 'message': 'Неверное имя пользователя или пароль!'}), 401 + else: flash('Неверное имя пользователя или пароль!', 'error'); return redirect(url_for('login')) + + # GET request html = ''' - Zeus Cloud - + Zeus Cloud V2 - Вход + + -
-

Zeus Cloud

-
- {% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} -
-
- - - -
-

Нет аккаунта? Зарегистрируйтесь

+
+

Zeus Cloud V2

+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} +
+
+
+ + +
+
+ + +
+ +
+

Нет аккаунта? Зарегистрируйтесь

@@ -474,802 +818,1187 @@ def login(): ''' return render_template_string(html) -@app.route('/dashboard', methods=['GET', 'POST']) -@app.route('/dashboard/', methods=['GET', 'POST']) -def dashboard(folder_path=''): - if 'username' not in session: - flash('Пожалуйста, войдите в систему!') - return redirect(url_for('login')) +@app.route('/dashboard/', defaults={'path': ''}) +@app.route('/dashboard/', methods=['GET', 'POST']) +@login_required +def dashboard(path): username = session['username'] data = load_data() - if username not in data['users']: - session.pop('username', None) - flash('Пользователь не найден!') - return redirect(url_for('login')) - current_folder = folder_path if folder_path else 'root' - user_folders = [f for f in data['users'][username].get('folders', []) if f['parent'] == current_folder] - user_files = [f for f in data['users'][username].get('files', []) if f['folder'] == current_folder] - user_folders = sorted(user_folders, key=lambda x: x['name']) - user_files = sorted(user_files, key=lambda x: x['upload_date'], reverse=True) + user_data = data['users'][username] + current_path_list = parse_path(path) + + if current_path_list is None: + flash('Недопустимый путь!', 'error') + return redirect(url_for('dashboard', path='')) # Redirect to root + + current_folder_obj = navigate_to_path(user_data['root'], current_path_list) + + if current_folder_obj is None: + flash('Папка не найдена!', 'error') + return redirect(url_for('dashboard', path='')) # Redirect to root + + # Ensure structure is correct + current_folder_obj.setdefault('files', []) + current_folder_obj.setdefault('folders', {}) + if request.method == 'POST': - if 'create_folder' in request.form: - folder_name = request.form.get('folder_name') - if not folder_name: - flash('Имя папки обязательно!') - return redirect(url_for('dashboard', folder_path=folder_path)) - folder_id = str(uuid.uuid4()) - folder_path_full = f"{current_folder}/{folder_name}" if current_folder != 'root' else folder_name - data['users'][username].setdefault('folders', []).append({ - 'id': folder_id, - 'name': folder_name, - 'parent': current_folder, - 'path': folder_path_full, - 'created_at", - "created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S') - }) - save_data(data) - flash('Папка успешно создана!') - return redirect(url_for('dashboard', folder_path=folder_path)) files = request.files.getlist('files') - if files and len(files) > 20: - flash('Максим��м 20 файлов за раз!') - return redirect(url_for('dashboard', folder_path=folder_path)) - if files: - os.makedirs('uploads', exist_ok=True) - api = HfApi() - temp_files = [] - for file in files: - if file and file.filename: - original_filename = secure_filename(file.filename) - unique_filename = generate_unique_filename(original_filename) - temp_path = os.path.join('uploads', unique_filename) + if not files or not files[0].filename: # Check if any file was actually selected + flash('Файлы для загрузки не выбраны.', 'warning') + return redirect(url_for('dashboard', path=path)) + + if len(files) > 20: # Limit concurrent uploads + flash('Максимум 20 файлов за раз!', 'warning') + return redirect(url_for('dashboard', path=path)) + + uploaded_count = 0 + error_count = 0 + api = _get_hf_api() + if not api: + flash('Ошибка конфигурации сервера: не удается получить доступ к хранилищу.', 'error') + return redirect(url_for('dashboard', path=path)) + + temp_upload_dir = os.path.join('uploads', username, *current_path_list) + os.makedirs(temp_upload_dir, exist_ok=True) + + for file in files: + if file and file.filename: # and allowed_file(file.filename): # Re-enable if needed + original_filename = secure_filename(file.filename) + if not is_safe_name(original_filename, MAX_FILENAME_LENGTH): + logging.warning(f"Skipping file with invalid name: {original_filename}") + error_count += 1 + continue + + unique_filename = generate_unique_filename(original_filename) + hf_path = build_hf_path(username, current_path_list, unique_filename) + temp_path = os.path.join(temp_upload_dir, unique_filename) # Save with unique name locally too + + try: file.save(temp_path) - temp_files.append((temp_path, unique_filename, original_filename)) - for temp_path, unique_filename, original_filename in temp_files: - file_path = f"cloud_files/{username}/{unique_filename}" - api.upload_file( - path_or_fileobj=temp_path, - path_in_repo=file_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Uploaded file for {username}" - ) - file_info = { - 'filename': original_filename, - 'unique_filename': unique_filename, - 'path': file_path, - 'type': get_file_type(original_filename), - 'upload_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'folder': current_folder - } - data['users'][username]['files'].append(file_info) - if os.path.exists(temp_path): - os.remove(temp_path) - save_data(data) - flash('Файлы успешно загружены!') - return redirect(url_for('dashboard', folder_path=folder_path)) - parent_folder = 'root' if current_folder == 'root' else '/'.join(current_folder.split('/')[:-1]) + logging.info(f"Uploading {original_filename} as {unique_filename} 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 {username} uploaded {original_filename} to {path}" + ) + + file_info = { + 'original_filename': original_filename, + 'unique_filename': unique_filename, # Stored name on HF + 'path': hf_path, # Full path on HF + 'type': get_file_type(original_filename), + 'upload_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + # Add size later if needed: 'size': os.path.getsize(temp_path) + } + current_folder_obj['files'].append(file_info) + uploaded_count += 1 + + except Exception as e: + logging.error(f"Failed to upload {original_filename}: {e}", exc_info=True) + error_count += 1 + finally: + # Clean up temp file + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError as e: + logging.error(f"Error removing temporary file {temp_path}: {e}") + + # Cleanup potentially empty temp dirs + try: + if not os.listdir(temp_upload_dir): + os.rmdir(temp_upload_dir) + # Could recursively go up and delete empty parent dirs if needed + except OSError: + pass # Ignore if dir is not empty or cannot be removed + + if uploaded_count > 0: + try: + save_data(data) + flash(f'{uploaded_count} файл(ов) успешно загружено.', 'success') + except Exception as e: + flash('Файлы загружены в хранилище, но произошла ошибка при обновлении списка файлов. Попробуйте обновить страницу.', 'error') + logging.error(f"Failed to save data after uploads for {username}: {e}") + + if error_count > 0: + flash(f'Не удалось загрузить {error_count} файл(ов). Проверьте логи сервера для деталей.', 'error') + + # Use JS for progress bar, redirect might interrupt it + # Return JSON for AJAX upload handler if implemented, otherwise redirect + # For now, simple redirect + return redirect(url_for('dashboard', path=path)) + + + # GET request or after POST redirect + sorted_files = sorted(current_folder_obj['files'], key=lambda x: x.get('upload_date', ''), reverse=True) + sorted_folders = sorted(current_folder_obj['folders'].keys()) + breadcrumbs = get_breadcrumbs(path) + html = ''' - Панель управления - Zeus Cloud - + Панель управления - Zeus Cloud V2 + + + +
-

Панель управления Zeus Cloud

-

Пользователь: {{ username }}

- {% with messages = get_flashed_messages() %} +
+

Панель управления

+
+ {{ username }} + Выйти +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} - {% for message in messages %} -
{{ message }}
+ {% for category, message in messages %} +
{{ message }}
{% endfor %} {% endif %} {% endwith %} -

Текущая папка: {{ current_folder }}

- {% if current_folder != 'root' %} - Назад - {% endif %} -
- - -
-
- - -
-
-
-
-

Папки

-
- {% for folder in user_folders %} -
- {{ folder['name'] }} -

{{ folder['created_at'] }}

- Удалить -
+ + + + + +
+
+
+ + +
+ +
+
+
+ + |]+" maxlength="100"> + +
+
-

Файлы

-

Ваши файлы под надежной защитой квантовой криптографии

+
0%
+ + +

Содержимое папки '{{ breadcrumbs[-1].name }}'

- {% for file in user_files %} -
- {% if file['type'] == 'video' %} -
-

Добавить на главный экран

-

Для быстрого доступа к Zeus Cloud, вы можете добавить это приложение на главный экран вашего телефона:

-
-

Для пользователей Android:

-
    -
  1. Откройте Zeus Cloud в браузере Chrome.
  2. -
  3. Нажмите на меню браузера (обычно три точки вверху справа).
  4. -
  5. Выберите "Добавить на главный экран".
  6. -
  7. Подтвердите добавление, и иконка приложения появится на вашем главном экране.
  8. -
+ + +
+

Добавить на главный экран

+

Для быстрого доступа добавьте Zeus Cloud на главный экран телефона (PWA).

+
    +
  1. Android (Chrome): Меню (три точки) -> "Установить приложение" или "Добавить на главный экран".
  2. +
  3. iOS (Safari): Кнопка "Поделиться" (квадрат со стрелкой) -> "На экран «Домой»".
  4. +
+
+ +
+ + +